The Vault
A vault allows users to securely store their assets. A vault is a fundamental building block in DeFi that, at its core, allows users to securely store their assets (lamports in this case) that only that same user can withdraw later.
In this challenge, we'll build a simple lamport vault that demonstrates how to work with basic accounts, Program Derived Addresses (PDAs), and Cross-Program Invocation (CPI). If you're not familiar with Pinocchio, you should start by reading the Introduction to Pinocchio to familiarize with the core concepts that we're going to use in this program.
Installation
Before you begin, be sure Rust and Pinocchio are installed. Then in your terminal run:
# create workspace
cargo new blueshift_vault --lib --edition 2021
cd blueshift_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"]
Template
Let's start with the basic program structure. We'll implement everything in lib.rs
since this is a straightforward program. Here's the initial template with the core components we'll need:
#![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
The deposit instruction performs the following steps:
- Verifies the vault is empty (has zero lamports) to prevent double deposits
- Ensures the deposit amount exceeds the rent-exempt minimum for a basic account
- Transfers lamports from the owner to the vault using a CPI to the System Program
First, let's define the account struct for deposit:
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 })
}
}
Let's break down each account check:
owner
: Must be a signer since they need to authorize the transactionvault
:- Must be owned by the System Program
- Must have zero lamports (ensures "fresh" deposit)
- Must be derived from the correct seeds
- Must match the expected PDA address
Now let's implement 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 })
}
}
Here we just check that the amount is different from zero.
Finally, let's implement the deposit instruction:
pub struct Deposit<'a> {
pub accounts: DepositAccounts<'a>,
pub instruction_data: 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_data = DepositInstructionData::try_from(data)?;
Ok(Self {
accounts,
instruction_data,
})
}
}
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_data.amount,
}
.invoke()?;
Ok(())
}
}
Withdraw
The withdraw instruction performs the following steps:
- Verifies the vault contains lamports (is not empty)
- Uses the vault's PDA to sign the transfer on its own behalf
- Transfers all lamports from the vault back to the owner
First, let's define the account struct for withdraw:
pub struct WithdrawAccounts<'a> {
pub owner: &'a AccountInfo,
pub vault: &'a AccountInfo,
pub bumps: [u8; 1],
}
impl<'a> TryFrom<&'a [AccountInfo]> for WithdrawAccounts<'a> {
type Error = ProgramError;
fn try_from(accounts: &'a [AccountInfo]) -> Result<Self, Self::Error> {
let [owner, vault, _] = 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);
}
if self.accounts.vault.lamports().eq(&0) {
return Err(ProgramError::InvalidAccountData);
}
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] })
}
}
Let's break down each account check:
owner
: Must be a signer since they need to authorize the transactionvault
:- Must be owned by the System Program
- Must be derived from the correct seeds
- Must match the expected PDA address
bumps
: We store the bump seed for use in the PDA signing
Now let's implement the withdraw instruction:
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 PDA signer seeds
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 all lamports from vault to owner
Transfer {
from: self.accounts.vault,
to: self.accounts.owner,
lamports: self.accounts.vault.lamports(),
}
.invoke_signed(&signers)?;
Ok(())
}
}
The security of this withdrawal is guaranteed by two factors:
- The vault's PDA is derived using the owner's public key, ensuring only the original depositor can withdraw
- The PDA's ability to sign the transfer is verified through the seeds we provide to
invoke_signed
Conclusion
You can now test your program against our unit tests and claim your NFTs!
Start by building your program using the following command in your terminal:
cargo build-sbf
This generated a .so
file directly in your target/deploy
folder.
Now click on the take challenge
button and drop the file there!