Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/blueshift-gg/quasar/llms.txt

Use this file to discover all available pages before exploring further.

The vault program shows how to use Quasar to custody native SOL in a per-user program-derived address. A user deposits any amount of lamports into their personal vault PDA via a System Program CPI, and later withdraws them with direct lamport manipulation — no token mints, no SPL accounts, just raw SOL. This example is deliberately minimal so you can focus on the PDA address constraint, the two distinct CPI strategies (System Program transfer vs. direct set_lamports), and how the Quasar accounts context wires everything together.

Program Entry Point

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)
    }
}
The dispatcher is two lines. Each instruction delegates immediately to the method defined on its accounts struct so that all account-level logic stays co-located with the accounts it operates on.

PDA Design

The vault derives its address from two seeds: the literal byte string "vault" and the user’s public key. This guarantees a unique, deterministic vault address per user per program.
#[derive(Seeds)]
#[seeds(b"vault", user: Address)]
pub struct VaultPda;
#[derive(Seeds)] generates a VaultPda::seeds(user: &Address) function that returns the canonical program-derived address. This function is used in the address = constraint on the vault account to verify at validation time that the caller provided the correct PDA, not an arbitrary account. On the client side:
/// Seeds: [b"vault", user]
pub fn find_vault_address(user: &Address, program_id: &Address) -> (Address, u8) {
    Address::find_program_address(&[b"vault", user.as_ref()], program_id)
}

Instruction 1: Deposit

The deposit instruction uses the System Program’s transfer instruction to move lamports from the user’s wallet into the vault PDA.

Accounts

#[derive(Accounts)]
pub struct Deposit {
    #[account(mut)]
    pub user: Signer,
    #[account(mut, address = VaultPda::seeds(user.address()))]
    pub vault: UncheckedAccount,
    pub system_program: Program<SystemProgram>,
}
AccountTypeConstraintPurpose
userSignermutSigns the transaction and funds the transfer
vaultUncheckedAccountmut, address = VaultPda::seeds(user.address())Destination PDA; no discriminator check needed because it holds plain SOL
system_programProgram<SystemProgram>Required for the CPI
UncheckedAccount is the correct type here because a fresh vault PDA has no data or owner set until the first deposit arrives — it is just an address with lamports. Quasar’s address = VaultPda::seeds(user.address()) constraint replaces the need for an owner or discriminator check by verifying the PDA derivation directly.

Implementation

impl Deposit {
    #[inline(always)]
    pub fn deposit(&self, amount: u64) -> Result<(), ProgramError> {
        self.system_program
            .transfer(&self.user, &self.vault, amount)
            .invoke()
    }
}
The System Program transfer CPI requires the sender (user) to be a signer, which Quasar has already verified through the Signer type. The call is a single method chain that compiles down to a direct sol_invoke_signed syscall with no intermediate allocations.

Client Instruction

pub struct DepositInstruction {
    pub user: Address,
    pub vault: Address,
    pub system_program: Address,
    pub amount: u64,
}

impl From<DepositInstruction> for Instruction {
    fn from(ix: DepositInstruction) -> Instruction {
        let accounts = vec![
            AccountMeta::new(ix.user, true),
            AccountMeta::new(ix.vault, false),
            AccountMeta::new_readonly(ix.system_program, false),
        ];
        let mut data = vec![0]; // discriminator = 0
        wincode::serialize_into(&mut data, &ix.amount).unwrap();
        Instruction {
            program_id: ID,
            accounts,
            data,
        }
    }
}

Instruction 2: Withdraw

The withdraw instruction does not use a CPI — it mutates the vault and user lamport counters directly. This is possible because the vault PDA is owned by the program, so the program has permission to reduce its lamport balance. The System Program only allows the account owner to debit; since this program is the owner of the vault PDA, it can write lamports freely.

Accounts

#[derive(Accounts)]
pub struct Withdraw {
    #[account(mut)]
    pub user: Signer,
    #[account(mut, address = VaultPda::seeds(user.address()))]
    pub vault: UncheckedAccount,
}
The system_program is not required here because no CPI is issued. The address = constraint still ensures only the genuine vault PDA for this user can be passed.

Implementation

impl Withdraw {
    #[inline(always)]
    pub fn withdraw(&self, amount: u64) -> Result<(), ProgramError> {
        let vault = self.vault.to_account_view();
        let user = self.user.to_account_view();
        let vault_lamports = vault
            .lamports()
            .checked_sub(amount)
            .ok_or(ProgramError::InsufficientFunds)?;
        let user_lamports = user
            .lamports()
            .checked_add(amount)
            .ok_or(ProgramError::ArithmeticOverflow)?;
        set_lamports(vault, vault_lamports);
        set_lamports(user, user_lamports);
        Ok(())
    }
}
to_account_view() returns a thin pointer into the SVM input buffer for that account. set_lamports writes through that pointer directly — no serialization, no copies, no CPI overhead.
Always use checked_sub and checked_add when manipulating lamports. Underflowing the vault’s balance or overflowing the user’s balance would silently corrupt on-chain state without the overflow checks.

Client Instruction

pub struct WithdrawInstruction {
    pub user: Address,
    pub vault: Address,
    pub amount: u64,
}

impl From<WithdrawInstruction> for Instruction {
    fn from(ix: WithdrawInstruction) -> Instruction {
        let accounts = vec![
            AccountMeta::new(ix.user, true),
            AccountMeta::new(ix.vault, false),
        ];
        let mut data = vec![1]; // discriminator = 1
        wincode::serialize_into(&mut data, &ix.amount).unwrap();
        Instruction {
            program_id: ID,
            accounts,
            data,
        }
    }
}

Vault Lifecycle

1

Derive the vault address off-chain

Before building any instruction the client computes the vault PDA:
let (vault, _bump) = find_vault_address(&user, &PROGRAM_ID);
This address is passed as the vault account in both DepositInstruction and WithdrawInstruction. The program re-derives it on-chain via the address = VaultPda::seeds(user.address()) constraint and rejects any mismatched address.
2

Deposit SOL

Build and send a DepositInstruction. The System Program transfers amount lamports from the user’s wallet to the vault PDA. The vault account need not exist yet — the System Program will create it with zero data and assign ownership to the vault program.
let ix: Instruction = DepositInstruction {
    user,
    vault,
    system_program: system_program::ID,
    amount: 1_000_000_000, // 1 SOL
}.into();
3

Withdraw SOL

Build and send a WithdrawInstruction. The program subtracts amount from the vault’s lamport counter and adds it to the user’s counter using direct in-place writes — there is no outbound CPI.
let ix: Instruction = WithdrawInstruction {
    user,
    vault,
    amount: 500_000_000, // 0.5 SOL
}.into();

Test Suite

Quasar ships an embedded SVM test runner. The vault tests demonstrate the expected lamport balances after each operation:
#[test]
fn test_deposit() {
    let mut svm = setup();

    let user = USER;
    let system_program = quasar_svm::system_program::ID;
    let (vault, _) = Pubkey::find_program_address(&[b"vault", user.as_ref()], &crate::ID);

    let deposit_amount: u64 = 1_000_000_000;

    let instruction: Instruction = DepositInstruction {
        user,
        vault,
        system_program,
        amount: deposit_amount,
    }
    .into();

    let result = svm.process_instruction(&instruction, &[signer(user), empty(vault)]);

    assert!(result.is_ok(), "deposit failed: {:?}", result.raw_result);

    let user_after = result.account(&user).unwrap().lamports;
    let vault_after = result.account(&vault).unwrap().lamports;

    assert_eq!(user_after, 10_000_000_000 - deposit_amount, "user lamports after deposit");
    assert_eq!(vault_after, deposit_amount, "vault lamports after deposit");
}

#[test]
fn test_withdraw() {
    let mut svm = setup();

    let user = USER;
    let (vault, _) = Pubkey::find_program_address(&[b"vault", user.as_ref()], &crate::ID);

    let vault_lamports: u64 = 1_000_000_000;
    let withdraw_amount: u64 = 500_000_000;

    let withdraw_ix: Instruction = WithdrawInstruction {
        user,
        vault,
        amount: withdraw_amount,
    }
    .into();

    let result = svm.process_instruction(
        &withdraw_ix,
        &[
            signer(user),
            Account {
                address: vault,
                lamports: vault_lamports,
                data: vec![],
                owner: crate::ID,
                executable: false,
            },
        ],
    );

    assert!(result.is_ok(), "withdraw failed: {:?}", result.raw_result);

    let user_final = result.account(&user).unwrap().lamports;
    let vault_final = result.account(&vault).unwrap().lamports;

    assert_eq!(user_final, 10_000_000_000 + withdraw_amount, "user lamports after withdraw");
    assert_eq!(vault_final, vault_lamports - withdraw_amount, "vault lamports after withdraw");
}
Run the full suite with:
quasar test

Extending the Vault

Add a withdrawal fee

In withdraw, deduct a basis-point fee before crediting the user and send it to a treasury account. Add the treasury as an UncheckedAccount in the Withdraw struct and call set_lamports on it.

Multiple vaults per user

Add a u64 nonce seed to VaultPda#[seeds(b"vault", user: Address, id: u64)] — so each user can open many independent vaults identified by a numeric ID.

Timed unlock

Store a lock_until: i64 Unix timestamp in a #[account] struct co-located at the vault PDA. Check Clock::get()?.unix_timestamp >= lock_until at the top of withdraw and return an error if the vault is still locked.

Multi-owner vault

Replace user: Address in the PDA seeds with a multisig config address and require m-of-n signatures in the Withdraw accounts struct using remaining_accounts.
The Quasar repository also includes a multisig example at examples/multisig/ that demonstrates CtxWithRemaining, dynamic signer parsing with remaining_accounts().parse::<Signer, 10>(), and a Vec<Address, 10> field capped at compile time. It is a good next step after the vault if you need multi-party authorization patterns.

Build docs developers (and LLMs) love