The Vault Code
Now that you know the moving parts of a native Solana program, let’s build an escrow-style vault you can deposit into and withdraw from.
The flow is simple:
- A user deposits lamports into a vault PDA derived from their key.
- Later, only that same user can withdraw the funds.
Before starting we need to create a new project, to do so run this command in your terminal:
# create workspace
cargo new naro_vault --lib --edition 2021
cd naro_vault
Add Pinocchio:
cargo add pinocchio pinocchio-system
Declare the crate types in Cargo.toml
to generate deployment artifacts in target/deploy
:
[lib]
crate-type = ["lib", "cdylib"]
You’re now ready to write your first native program.
The Vault Code
lib.rs
begins with the entrypoint and a dispatcher:
#![no_std]
use pinocchio::{account_info::AccountInfo, entrypoint, nostd_panic_handler, program_error::ProgramError, pubkey::Pubkey, ProgramResult};
entrypoint!(process_instruction);
nostd_panic_handler!();
pub mod instructions;
pub use instructions::*;
// 22222222222222222222222222222222222222222222
pub const ID: Pubkey = [
0x0f, 0x1e, 0x6b, 0x14, 0x21, 0xc0, 0x4a, 0x07,
0x04, 0x31, 0x26, 0x5c, 0x19, 0xc5, 0xbb, 0xee,
0x19, 0x92, 0xba, 0xe8, 0xaf, 0xd1, 0xcd, 0x07,
0x8e, 0xf8, 0xaf, 0x70, 0x47, 0xdc, 0x11, 0xf7,
];
fn process_instruction(
_program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
match instruction_data.split_first() {
Some((Deposit::DISCRIMINATOR, data)) => Deposit::try_from((data, accounts))?.process(),
Some((Withdraw::DISCRIMINATOR, _)) => Withdraw::try_from(accounts)?.process(),
_ => Err(ProgramError::InvalidInstructionData),
}
}
Deposit
Goals
- Create an empty vault PDA for the caller if it doesn’t exist.
- Move lamports from the signer to that vault.
Validation steps
- Accounts slice must include signer, vault PDA, and System Program.
- Signer must have signed the transaction.
- Vault must be:
- Owned by the System Program.
- Holding zero lamports (ensures “fresh” deposit).
- Derived deterministically from [b"vault", signer_pubkey].
- Instruction data must be exactly 8 bytes (one u64 amount) and non-zero.
Execution We're going to start with the account struct (we already described this in the last section so we're going to gloss over):
pub struct DepositAccounts<'a> {
pub owner: &'a AccountInfo,
pub vault: &'a AccountInfo,
}
impl<'a> TryFrom<&'a [AccountInfo]> for DepositAccounts<'a> {
type Error = ProgramError;
fn try_from(accounts: &'a [AccountInfo]) -> Result<Self, Self::Error> {
let [owner, vault, _] = accounts else {
return Err(ProgramError::NotEnoughAccountKeys);
};
// Accounts Checks
if !owner.is_signer() {
return Err(ProgramError::InvalidAccountOwner);
}
if !vault.is_owned_by(&pinocchio_system::ID) {
return Err(ProgramError::InvalidAccountOwner);
}
if vault.lamports().ne(&0) {
return Err(ProgramError::InvalidAccountData);
}
let (vault_key, _) = find_program_address(&[b"vault", owner.key()], &crate::ID);
if vault.key().ne(&vault_key) {
return Err(ProgramError::InvalidAccountOwner);
}
// Return the accounts
Ok(Self { owner, vault })
}
}
And here's the instruction data struct:
pub struct DepositInstructionData {
pub amount: u64,
}
impl<'a> TryFrom<&'a [u8]> for DepositInstructionData {
type Error = ProgramError;
fn try_from(data: &'a [u8]) -> Result<Self, Self::Error> {
if data.len() != size_of::<u64>() {
return Err(ProgramError::InvalidInstructionData);
}
let amount = u64::from_le_bytes(data.try_into().unwrap());
// Instruction Checks
if amount.eq(&0) {
return Err(ProgramError::InvalidInstructionData);
}
Ok(Self { amount })
}
}
We can finally pass to the business logic where we're just going to perform the transfer of the lamports from the owner to the vault using the pinocchio_system
transfer helper like this:
pub struct Deposit<'a> {
pub accounts: DepositAccounts<'a>,
pub instruction_datas: DepositInstructionData,
}
impl<'a> TryFrom<(&'a [u8], &'a [AccountInfo])> for Deposit<'a> {
type Error = ProgramError;
fn try_from((data, accounts): (&'a [u8], &'a [AccountInfo])) -> Result<Self, Self::Error> {
let accounts = DepositAccounts::try_from(accounts)?;
let instruction_datas: DepositInstructionData = DepositInstructionData::try_from(data)?;
Ok(Self {
accounts,
instruction_datas,
})
}
}
impl<'a> Deposit<'a> {
pub const DISCRIMINATOR: &'a u8 = &0;
pub fn process(&mut self) -> ProgramResult {
Transfer {
from: self.accounts.owner,
to: self.accounts.vault,
lamports: self.instruction_datas.amount,
}
.invoke()?;
Ok(())
}
}
Withdraw
Goals
- Let the same signer reclaim all lamports in the vault.
- Require a PDA signature so only that vault can authorize the transfer.
Validation steps
- Accounts slice: signer, vault PDA, System Program.
- Signer must sign.
- Vault must be owned by the System Program.
- Vault PDA must match [b"vault", signer_pubkey], capturing the bump used in derivation.
Execution
We're going to start with the account struct:
pub struct WithdrawAccounts<'a> {
pub owner: &'a AccountInfo,
pub vault: &'a AccountInfo,
pub bumps: [u8; 1],
}
// Perform sanity checks on the accounts
impl<'a> TryFrom<&'a [AccountInfo]> for WithdrawAccounts<'a> {
type Error = ProgramError;
fn try_from(accounts: &'a [AccountInfo]) -> Result<Self, Self::Error> {
let [owner, vault, _system_program] = accounts else {
return Err(ProgramError::NotEnoughAccountKeys);
};
// Basic Accounts Checks
if !owner.is_signer() {
return Err(ProgramError::InvalidAccountOwner);
}
if !vault.is_owned_by(&pinocchio_system::ID) {
return Err(ProgramError::InvalidAccountOwner);
}
let (vault_key, bump) = find_program_address(&[b"vault", owner.key().as_ref()], &crate::ID);
if &vault_key != vault.key() {
return Err(ProgramError::InvalidAccountOwner);
}
Ok(Self { owner, vault, bumps: [bump] })
}
}
The checks performed are the same as the one performed in the Deposit, but this time around in the account struct we saved the bump as well since we're going to need it in the CPI in the process()
function.
For the withdrawal we don't need any instruction data so we can omit the creation of the instruction struct and just skip to the business logic:
pub struct Withdraw<'a> {
pub accounts: WithdrawAccounts<'a>,
}
impl<'a> TryFrom<&'a [AccountInfo]> for Withdraw<'a> {
type Error = ProgramError;
fn try_from(accounts: &'a [AccountInfo]) -> Result<Self, Self::Error> {
let accounts = WithdrawAccounts::try_from(accounts)?;
Ok(Self { accounts })
}
}
impl<'a> Withdraw<'a> {
pub const DISCRIMINATOR: &'a u8 = &1;
pub fn process(&mut self) -> ProgramResult {
// Create signer seeds for our CPI
let seeds = [
Seed::from(b"vault"),
Seed::from(self.accounts.owner.key().as_ref()),
Seed::from(&self.accounts.bumps),
];
let signers = [Signer::from(&seeds)];
Transfer {
from: self.accounts.vault,
to: self.accounts.owner,
lamports: self.accounts.vault.lamports(),
}
.invoke_signed(&signers)?;
Ok(())
}
}
Here we use the invoke_signed
capabilities of PDAs, and we create the Seed
struct from the deterministic seeds and the bump that we derived in the account struct before during our checks.
`