Build a Vault
Step-by-step guide to building a SOL vault program with Quasar.
Build a SOL vault that lets users deposit SOL into a PDA and withdraw it later. This is the simplest complete Quasar program.
You will learn to:
- Define account structs with PDA seeds and constraints
- Transfer SOL via system program CPI
- Withdraw lamports directly from a PDA
- Test with Mollusk
Project setup
Create a new project:
quasar init quasar-vault
cd quasar-vaultCargo.toml:
[package]
name = "quasar-vault"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[features]
alloc = []
client = []
debug = []
[dependencies]
quasar-lang = { version = "0.1.0" }crate-type = ["cdylib"] produces a shared library the Solana runtime can load. The debug feature enables verbose error logging.
File structure
Two instructions, no persistent state account:
src/
lib.rs # Program entrypoint and instruction dispatch
instructions/
mod.rs # Re-exports
deposit.rs # Deposit SOL into the vault PDA
withdraw.rs # Withdraw SOL from the vault PDAThe vault is an UncheckedAccount PDA -- no #[account] struct needed since it only holds lamports.
The program entrypoint
lib.rs declares the program ID and instruction dispatch:
#![no_std]
use quasar_lang::prelude::*;
mod instructions;
use instructions::*;
declare_id!("33333333333333333333333333333333333333333333");
#[program]
mod quasar_vault {
use super::*;
#[instruction(discriminator = 0)]
pub fn deposit(ctx: Ctx<Deposit>, amount: u64) -> Result<(), ProgramError> {
ctx.accounts.deposit(amount)
}
#[instruction(discriminator = 1)]
pub fn withdraw(ctx: Ctx<Withdraw>, amount: u64) -> Result<(), ProgramError> {
ctx.accounts.withdraw(amount)
}
}#![no_std]-- Quasar programs run in the SBF runtime without the standard library.declare_id!-- Sets the program's on-chain address. Replace with your actual program ID after deployment.#[program]-- Generates the entrypoint, dispatch table, panic handler, and allocator. See Program Structure.#[instruction(discriminator = 0)]-- Each instruction gets a unique discriminator byte for routing.Ctx<Deposit>-- Typed context providing validated accounts and PDA bumps.
Both handlers delegate to methods on the accounts struct -- keep the #[program] module as a routing layer and put logic in the impl block.
instructions/mod.rs re-exports both modules:
pub mod deposit;
pub use deposit::*;
pub mod withdraw;
pub use withdraw::*;The deposit instruction
Create instructions/deposit.rs:
use quasar_lang::prelude::*;
#[derive(Accounts)]
pub struct Deposit<'info> {
pub user: &'info mut Signer,
#[account(mut, seeds = [b"vault", user], bump)]
pub vault: &'info mut UncheckedAccount,
pub system_program: &'info Program<System>,
}
impl<'info> Deposit<'info> {
#[inline(always)]
pub fn deposit(&self, amount: u64) -> Result<(), ProgramError> {
self.system_program
.transfer(self.user, self.vault, amount)
.invoke()
}
}The accounts struct
#[derive(Accounts)] generates deserialization and validation. Each field:
user: &'info mut Signer-- The wallet depositing SOL.Signerverifies the transaction signature.mutbecause its lamports decrease during transfer.vault: &'info mut UncheckedAccount-- The vault PDA receiving SOL.seeds = [b"vault", user]verifies the PDA derivation.bumpderives and stores the bump seed inctx.bumps. We useUncheckedAccountbecause the vault holds only lamports -- no data to deserialize.system_program: &'info Program<System>-- Required for the transfer CPI.Program<System>validates this is the system program.
The handler
The deposit method performs a system program transfer from the user to the vault:
self.system_program
.transfer(self.user, self.vault, amount)
.invoke().transfer() constructs the CPI instruction and .invoke() executes it. CPI builders are type-safe and stack-allocated. See Cross-Program Invocations.
#[inline(always)] avoids function call overhead to minimize compute units.
The withdraw instruction
Create instructions/withdraw.rs:
use quasar_lang::prelude::*;
#[derive(Accounts)]
pub struct Withdraw<'info> {
pub user: &'info mut Signer,
#[account(mut, seeds = [b"vault", user], bump)]
pub vault: &'info mut UncheckedAccount,
}
impl<'info> Withdraw<'info> {
#[inline(always)]
pub fn withdraw(&self, amount: u64) -> Result<(), ProgramError> {
let vault = self.vault.to_account_view();
let user = self.user.to_account_view();
set_lamports(vault, vault.lamports() - amount);
set_lamports(user, user.lamports() + amount);
Ok(())
}
}The accounts struct
The Withdraw struct has no system_program field -- we do not need the system program for withdrawals.
The handler
Since the vault PDA is owned by our program, we can directly manipulate its lamports without a CPI:
let vault = self.vault.to_account_view();
let user = self.user.to_account_view();
set_lamports(vault, vault.lamports() - amount);
set_lamports(user, user.lamports() + amount);to_account_view() provides a low-level view of the account's data. set_lamports modifies both balances directly -- no CPI overhead.
This is safe because:
Signeronuserensures only the depositor can withdraw (vault PDA seeds include the user's address).- PDA seed verification on
vaultensures we modify the correct vault. - The Solana runtime verifies total lamports are conserved after execution.
How the PDA works
The vault PDA is derived from two seeds: "vault" and the user's public key, so every user gets a unique vault address:
vault_address = PDA(["vault", user_pubkey], program_id)seeds = [b"vault", user] generates code to derive the PDA, verify the passed-in account matches, and store the bump in ctx.bumps. See Program Derived Addresses.
Testing with Mollusk
Add the test dependencies:
[dev-dependencies]
mollusk-svm = "0.10.3"
solana-account = { version = "3.4.0" }
solana-instruction = { version = "3.2.0", features = ["bincode"] }Deposit test:
#[test]
fn test_deposit() {
let mollusk = setup();
let (system_program, system_program_account) = keyed_account_for_system_program();
let user = Address::new_unique();
let user_account = Account::new(10_000_000_000, 0, &system_program);
let (vault, _vault_bump) =
Address::find_program_address(&[b"vault", user.as_ref()], &crate::ID);
let vault_account = Account::new(0, 0, &system_program);
let deposit_amount: u64 = 1_000_000_000;
let instruction: Instruction = DepositInstruction {
user,
vault,
system_program,
amount: deposit_amount,
}
.into();
let result = mollusk.process_instruction(
&instruction,
&[
(user, user_account.clone()),
(vault, vault_account.clone()),
(system_program, system_program_account.clone()),
],
);
assert!(result.program_result.is_ok());
let user_after = result.resulting_accounts[0].1.lamports;
let vault_after = result.resulting_accounts[1].1.lamports;
assert_eq!(user_after, 10_000_000_000 - deposit_amount);
assert_eq!(vault_after, deposit_amount);
}The test sets up a user with 10 SOL, deposits 1 SOL, and verifies lamports moved correctly. The withdraw test follows the same pattern -- deposit first, then withdraw with only two accounts (no system program needed for direct lamport transfer).
Run tests:
cargo test-sbfSummary
- Account structs with constraints --
#[derive(Accounts)]generates validation, PDA derivation, and signer checks. - CPI for deposits -- Type-safe, stack-allocated system program transfer.
- Direct lamport manipulation for withdrawals -- No CPI overhead when your program owns the source account.
- PDA-based addressing -- Each user gets a deterministic vault address.
Next steps
- Build an Escrow -- a more complex program with SPL token transfers, account initialization, and event emission.
- Build a Multisig -- dynamic accounts, remaining accounts, and threshold-based authorization.
- Accounts and Validation -- deep dive into all account types and constraint attributes.
