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_vault
This 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
Clone
implementation 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
u8
value representing the bump seed for the vault's PDA. This ensures the uniqueness and security of the vault's address.state_bump: A
u8
value 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 from0
to255
.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 theVaultState
account that is initialized with the providedINIT_SPACE
. It is created with a deterministic seed, ensuring its address is unique and predictable. Thepayer
is set to theuser
, who will also cover the account creation costs.vault: SystemAccount<'info>
: This account is aSystemAccount
used to store lamports. It is created with a seed that combines thestate
account’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 (vault
andstate
account's key) and the bump value stored in theVaultState
.state: Account<'info, VaultState>
: This account holds theVaultState
data, including the bump values used for both thevault
andstate
accounts. 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
transfer
instruction.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
transfer
function, 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
seeds
array references byte arrays (b"vault"
and thestate
key) 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_signer
function 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
seeds
array is composed of the byte representation of the vault (b"vault"
), the key of theVaultState
account, 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
VaultState
account is closed using theclose
attribute in the struct definition. Theclose = user
attribute ensures that when theVaultState
account is closed, any remaining lamports are automatically transferred to the user's account.
System Account Limitation:
It's important to note that while the
VaultState
account 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`