Anchor Vault Program: A Deep Dive into Vault Development
Mastering Anchor Vaults: A Step-by-Step Guide to Building a Secure Solana Vault Program
In this blog, I’ll be sharing my journey and learnings with a particular focus on developing a Vault Solana Program using Anchor, Solana's powerful framework for building decentralized applications. Anchor simplifies the process of writing smart contracts by providing a set of tools and conventions that enhance developer productivity and reduce the likelihood of errors.
System Account
In Solana, a System Account is a type of account that is created and managed by the Solana System Program. This account holds lamports, which are the native currency of the Solana blockchain. System Accounts are essential for any on-chain operations because they provide the necessary funds to pay for transaction fees and storage costs.
Vault
A Vault in the context of Solana refers to a storage mechanism that holds assets, such as tokens. While System Accounts store lamports, Associated Token Accounts (ATA) are used to hold tokens. An ATA acts like a vault for a specific token and is linked to a user's wallet. This allows users to manage their tokens efficiently without needing to create a new account for every token they hold.
Initialize Your Anchor Vault Project
To begin, open your terminal and navigate to your desired project directory. Run the following command to initialize a new Anchor project named anchor_vault:
anchor init anchor_vaultThis will set up the basic structure for your Anchor Vault program, including the necessary directories and configuration files.
Understanding the #[account] Attribute in Anchor
When working with Solana and Anchor, you often need to define custom data structures that will be stored on-chain. These data structures, or accounts, are fundamental to how your program operates. Anchor provides the #[account] attribute, which makes it easier to define and manage these on-chain accounts.
What Does #[account] Do?
The #[account] attribute is a powerful macro provided by Anchor that automatically generates trait implementations for your data structures. Specifically, when you apply #[account] to a struct, it generates the following trait implementations:
- AccountSerialize: Allows the account to be serialized into bytes, which is necessary for storing the account's data on-chain. 
- AccountDeserialize: Provides deserialization functionality to read the account's data from its serialized form. 
- AnchorSerialize: Implements serialization for use with Anchor's custom serialization logic. 
- AnchorDeserialize: Implements deserialization for Anchor's custom serialization logic. 
- Clone: Generates a - Cloneimplementation to easily create copies of the account structure.
- Discriminator: Adds a unique discriminator to the account, which helps identify the type of account data being used. 
- Owner: Associates the account with the specific program that owns it, ensuring that only this program can modify the account. 
 How Does It Help?- By automatically generating these traits, the - #[account]attribute simplifies the process of serializing and deserializing your accounts. This is crucial in Solana programs, where data must be efficiently stored and retrieved from on-chain accounts.
Defining the VaultState Account
#[account]
pub struct VaultState {
    pub vault_bump: u8,
    pub state_bump: u8,
}The VaultState struct contains two fields:
- vault_bump: A - u8value representing the bump seed for the vault's PDA. This ensures the uniqueness and security of the vault's address.
- state_bump: A - u8value representing the bump seed for the state account's PDA. This is used to verify the integrity of the state account associated with the vault.
Bump Seed Explained
- Purpose: The bump seed is used to avoid collisions and ensure that the generated PDA is valid. It is essentially a nonce or additional value that makes the PDA derivation unique. 
- Usage: When creating or accessing PDAs, the bump seed helps in finding a unique address that hasn’t been used yet. The value of the bump seed is usually a single byte ( - u8), which can range from- 0to- 255.
- How It Works: During the derivation of a PDA, the bump seed is used in conjunction with other seeds (like the account’s public key and token mint address) to generate an address. If the generated address conflicts with an existing account, the bump seed is incremented until a unique address is found. 
Implementing the Space Trait for VaultState
To define how much space the VaultState account needs on the Solana blockchain, we implement the Space trait for our VaultState struct. This trait allows us to specify the amount of storage required for the account, ensuring that it has enough space to store all necessary data.
Here’s how we implement the Space trait for VaultState:
impl Space for VaultState {
    const INIT_SPACE: usize = 8 + 1 + 1;
}Breakdown of Space Calculation
- 8 bytes: This is the space required for the account’s discriminator, a unique identifier for the account's type. In Solana's Anchor framework, each account type includes an 8-byte discriminator to ensure that the account data is correctly identified. 
- 1 byte for - vault_bump: This field stores the bump seed for the vault’s Program Derived Address (PDA). It requires 1 byte of space.
- 1 byte for - state_bump: Similarly, this field holds the bump seed for the state account’s PDA, also requiring 1 byte of space.
Initialization Context for the Anchor Vault Program
The Initialize context handles the creation and initialization of essential accounts, ensuring they are properly configured and linked.
#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub user: Signer<'info>,
    #[account(
        init,
        payer = user,
        seeds = [b"state", user.key().as_ref()],
        bump,
        space = VaultState::INIT_SPACE,
    )]
    pub state: Account<'info, VaultState>,
    #[account(
        seeds = [b"vault", state.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) -> ProgramResult {
        self.state.vault_bump = bumps.vault;
        self.state.state_bump = bumps.state;
        Ok(())
    }
}Initialize Context
- user: Signer<'info>: The user initiating the initialization must sign the transaction. This ensures that the user authorizes the creation and configuration of the accounts.
- state: Account<'info, VaultState>: This is the- VaultStateaccount that is initialized with the provided- INIT_SPACE. It is created with a deterministic seed, ensuring its address is unique and predictable. The- payeris set to the- user, who will also cover the account creation costs.
- vault: SystemAccount<'info>: This account is a- SystemAccountused to store lamports. It is created with a seed that combines the- stateaccount’s key, ensuring it is derived in a deterministic manner. This account will hold and manage lamports, crucial for the vault's operations.
- system_program: Program<'info, System>: This is the System Program required to handle low-level operations, such as creating and managing accounts on the Solana blockchain.
initialize Method
The initialize method is responsible for setting the vault_bump and state_bump values in the VaultState account. These bump values are crucial for deriving valid PDAs and ensuring the uniqueness of the vault and state accounts.
Implementing the Deposit Context: Transferring SOL to the Vault
Deposit function will allow users to transfer SOL (the native currency of Solana) into the vault.
Here’s the code for the Deposit context and function:
#[derive(Accounts)]
pub struct Deposit<'info> {
    #[account(mut)]
    pub user: Signer<'info>,
    #[account(
        mut,
        seeds = [b"vault", state.key().as_ref()],
        bump = state.vault_bump,
    )]
    pub vault: SystemAccount<'info>,
    #[account(
        seeds = [b"state", user.key().as_ref()],
        bump = state.state_bump,
    )]
    pub state: Account<'info, VaultState>,
    pub system_program: Program<'info, System>,
}
impl<'info> Deposit<'info> {
    pub fn deposit(&mut self, amount: u64) -> Result<()> {
        let cpi_program = self.system_program.to_account_info();
        let cpi_accounts = Transfer {
            from: self.user.to_account_info(),
            to: self.vault.to_account_info(),
        };
        let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
        transfer(cpi_ctx, amount)?;
        Ok(())
    }
}Key Components 
- user: Signer<'info>: The user account that is signing the transaction. This is the source of the SOL being deposited into the vault.
- vault: SystemAccount<'info>: This account, defined as mutable (- mut), is where the deposited SOL will be stored. The vault is derived using a seed (- vaultand- stateaccount's key) and the bump value stored in the- VaultState.
- state: Account<'info, VaultState>: This account holds the- VaultStatedata, including the bump values used for both the- vaultand- stateaccounts. It ensures that the vault account can be securely accessed and managed.
- system_program: Program<'info, System>: This is the System Program, which facilitates the transfer of SOL between accounts.
The deposit Function
The deposit function is where the actual transfer of SOL takes place. Here’s how it works:
- CPI Context Setup: The function begins by setting up a Cross-Program Invocation (CPI) context, necessary for calling the system program’s - transferinstruction.- cpi_program: Represents the System Program required to execute the SOL transfer.
- cpi_accounts: Defines the source (- user) and destination (- vault) accounts involved in the transfer.
 
- Transfer Execution: Using the - transferfunction, SOL is transferred from the user's account to the vault account. This is done securely through the CPI context, ensuring that the operation adheres to Solana’s safety and consistency rules.
Handling Payments with PDA: Deposit and Withdrawal into same Context
Now we have to enchance our Solana program to support both deposits and withdrawals using a Payments context. Previously, the context was named Withdraw, but since we're now handling both deposit and withdrawal functionalities, it has been renamed to Payments.
#[derive(Accounts)]
pub struct Payments<'info> {
    #[account(mut)]
    pub user: Signer<'info>, // The user initiating the transaction
    #[account(
        mut,
        seeds = [b"vault", state.key().as_ref()],
        bump = state.vault_bump,
    )]
    pub vault: SystemAccount<'info>, // The vault account where funds are stored
    #[account(
        seeds = [b"state", user.key().as_ref()],
        bump = state.state_bump,
    )]
    pub state: Account<'info, VaultState>, // Stores the state of the vault
    pub system_program: Program<'info, System>, // The system program responsible for transfers
}Withdrawal Functionality
The withdraw function allows users to withdraw funds from the vault back to their accounts. However, since the vault is a Program Derived Address (PDA) without a private key, our program needs to sign the transaction on behalf of the vault using new_with_signer:
pub fn withdraw(&mut self, amount: u64) -> Result<()> {
    let cpi_program = self.system_program.to_account_info();
    let cpi_accounts = Transfer {
        from: self.vault.to_account_info(),
        to: self.user.to_account_info(),
    };
    let seeds = &[
        b"vault",  // byte representation of vault
        self.state.to_account_info().key.as_ref(),
        &[self.state.vault_bump]
    ];  // array of references to byte arrays
    let signer_seeds = &[&seeds[..]];
    let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, signer_seeds);
    transfer(cpi_ctx, amount)?;
    Ok(())
}In this function:
- Signer Seeds: The - seedsarray references byte arrays (- b"vault"and the- statekey) and the vault's bump. These seeds are used to derive the PDA that will act as the signer.
- CPI Context with Signer: The - CpiContext::new_with_signerfunction creates a CPI context where our program signs the transaction on behalf of the vault PDA.
Implementing Accounts Closure with PDA
Close context closes the Program Derived Addresses (PDAs) on the Solana blockchain but it cannot close Vault PDA since it is not an Account but it is a SystemAccount.
Struct Definition and Accounts
Here’s the breakdown of the Close struct:
#[derive(Accounts)]
pub struct Close<'info> {
    #[account(mut)]
    pub user: Signer<'info>, // The user initiating the close operation
    #[account(
        mut,
        seeds = [b"vault", state.key().as_ref()],
        bump = state.vault_bump,
    )]
    pub vault: SystemAccount<'info>, // The vault PDA where funds are stored
    #[account(
        mut,
        close = user,
        seeds = [b"state", user.key().as_ref()],
        bump = state.state_bump,
    )]
    pub state: Account<'info, VaultState>, // The state account to be closed
    pub system_program: Program<'info, System>,
}Handling Account Closure
The close function in our Close struct performs two critical tasks: transferring all lamports from the vault PDA to the user's account and securely closing the VaultState account.
impl<'info> Close<'info> {
    pub fn close(&mut self) -> Result<()> {
        let cpi_program = self.system_program.to_account_info();
        let cpi_accounts = Transfer {
            from: self.vault.to_account_info(),
            to: self.user.to_account_info(),
        };
        let balance = self.vault.get_lamports(); // Retrieve the balance in the vault
        let seeds = &[
            b"vault", // byte representation of vault
            self.state.to_account_info().key.as_ref(),
            &[self.state.vault_bump],
        ]; // array of references to byte arrays
        let signer_seeds = &[&seeds[..]];
        let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, signer_seeds);
        transfer(cpi_ctx, balance)?; // Transfer all lamports from vault to user
        Ok(())
    }
}Here’s what happens step by step:
- Transfer of Funds: - The function first retrieves the total lamports (Solana's native token) in the vault using - self.vault.get_lamports().
- A Cross-Program Invocation (CPI) context is then created using - CpiContext::new_with_signer. This allows the program to sign the transaction on behalf of the vault PDA using the derived seeds.
 
- Signer Seeds: - The - seedsarray is composed of the byte representation of the vault (- b"vault"), the key of the- VaultStateaccount, and the bump seed. These seeds are used to derive the PDA that will act as the signer for the transaction.
 
- Closing the Account: - The - VaultStateaccount is closed using the- closeattribute in the struct definition. The- close = userattribute ensures that when the- VaultStateaccount is closed, any remaining lamports are automatically transferred to the user's account.
 
- System Account Limitation: - It's important to note that while the - VaultStateaccount can be closed, the vault itself, being a system account, cannot be directly closed. Instead, all funds within the vault are transferred to the user's account, leaving the vault empty but intact.
 
Final Implementation
With the implementation of the anchor_vault program, we've now incorporated all the essential functions to handle deposits, withdrawals, and the closing of accounts within the vault system on Solana.
Here is the final breakdown:
#[program]
pub mod anchor_vault {
    use super::*;
    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let _ = ctx.accounts.initialize(&ctx.bumps);
        Ok(())
    }
    pub fn deposit(ctx: Context<Payments>, amount: u64) -> Result<()> {
        ctx.accounts.deposit(amount)?;
        Ok(())
    }
    pub fn withdraw(ctx: Context<Payments>, amount: u64) -> Result<()> {
        ctx.accounts.withdraw(amount)?;
        Ok(())
    }
    pub fn close(ctx: Context<Close>) -> Result<()> {
        ctx.accounts.close()?;
        Ok(())
    }
}Summary
The anchor_vault program now offers a complete functionalities necessary for managing user funds in a vault on Solana.
Bingo! You've successfully completed the program. Your vault system is now ready to roll on the Solana blockchain! 🎉



Is this really working? SystemAccount means `SystemAccount.info.owner == SystemProgram`. So, how can you then debit lamports with your program from a System owned account? I've implemented the example and it's not working, failing with `Transaction simulation failed: Error processing Instruction 0: instruction spent from the balance of an account it does not own`