Chapter 1: The Vault
While most Solana developers lean on Anchor, there are plenty of good reasons to write a program without it. Perhaps you need finer-grained control over every account field, or you’re chasing maximum performance, or you just simply want to avoid macros.
Writing Solana programs without a framework like Anchor is known as native development. It is more demanding, yet in this chapter you’ll learn to craft a Solana program from scratch with Pinocchio; a lightweight library that lets you skip external frameworks and own every byte of your code.
Pinocchio 101
Pinocchio is a minimalist Rust library that lets you craft Solana programs without pulling in the heavyweight solana-program
crate. It works by treating the incoming transaction payload: accounts, instruction data, everything; as a single byte slice and reads it in-place via zero-copy techniques.
The minimalist design unlocks three big benefits:
- Fewer compute units. No extra deserialization or memory copies.
- Smaller binaries. Leaner code paths mean a lighter
.so
on-chain. - Zero dependency drag. No external crates to update (or break).
The project was started by Febo at Anza with core contribution from the Solana ecosystem and the naro team, and lives here.
Alongside the core crate, you’ll find pinocchio-system
and pinocchio-token
, which give you zero-copy helpers and CPI utilities for Solana’s native System and SPL-Token programs.
Native Development
That might sound daunting, but it’s exactly why this chapter exists. By the end you’ll understand every byte that crosses the program boundary and how to keep your logic tight, secure, and fast.
Anchor uses Declarative Macros to simplify the boilerplate of dealing with accounts, instruction data, and error handling that are the core of building Solana Programs.
Going Native, means we don't have that luxury anymore and that we will need to:
- Create our own Discriminator and Entrypoint for the different Instructions
- Create our own Account, Instruction and deserialization logic
- Implement all the security checks that Anchor was doing for us before
The Discriminator
In Anchor, the #[program]
macro hides a lot of wiring. Under the hood it builds an 8-byte discriminator (size customizable since version 0.31) for every instruction by hashing "global:"
+ function_name with SHA-256:
pub fn get_anchor_discriminator_from_name(name: &str) -> [u8; 8] {
let mut hasher = Sha256::new();
hasher.update(format!("global:{}", name));
let result = hasher.finalize();
[result[0], result[1], result[2], result[3], result[4], result[5], result[6], result[7]]
}
Native programs usually keep things leaner. A single-byte discriminator (values 0x01…0xFF) is enough for up to 255 instructions, plenty for most use cases.
Need more? Switch to two bytes, and you jump to 65,535 possible variants.
Where the program starts
Every Solana program lands first in an entrypoint!
macro. There you receive three raw slices:
- program_id: the public key of the deployed program
- accounts: every account passed in the instruction
- instruction_data: an opaque byte array containing your discriminator plus any user-supplied data
A canonical dispatch pattern
When sending transactions, we usually place the discriminator in the first byte(s) of instruction_data
so we can route it to the appropriate handler like this:
entrypoint!(process_instruction);
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)
}
}
This is what the macro does behind the scene:
split_first()
peels off the discriminator byte.match
decides which instruction struct to instantiate.- Each instruction’s
try_from
implementation validates and deserializes its inputs. - A call to
process()
executes the business logic.
Accounts and Instructions
Since we’re writing native code, every account and every byte of instruction data must be validated by hand. To keep that work tidy, and fast, we like to define an Accounts struct for each instruction and implement Rust’s TryFrom trait on it.
The pattern gives us Anchor-style ergonomics without the macros and keeps the actual process()
method almost boilerplate-free.
Understanding the TryFrom
Trait
TryFrom
is part of Rust’s standard conversion family.
Unlike From
, which assumes a conversion can’t fail, TryFrom
returns a Result
, allowing you to surface errors early; perfect for on-chain validation.
The trait is defined like this:
pub trait TryFrom<T>: Sized {
type Error;
fn try_from(value: T) -> Result<Self, Self::Error>;
}
In a Solana program, we implement TryFrom
to turn raw account slices (and, when needed, instruction bytes) into strongly-typed structs while enforcing every constraint.
Account Validation with TryFrom
In each TryFrom
implementation, we usually implement the specific checks for accounts and instructions in order to leave the process()
function, where all the instruction logic happens, as barebone as possible.
Below is a trimmed example for a Deposit
instruction:
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> {
// 1. Destructure the slice
let [owner, vault, _] = accounts else {
return Err(ProgramError::NotEnoughAccountKeys);
};
// 2. Custom checks
if !owner.is_signer() {
return Err(ProgramError::InvalidAccountOwner);
}
if !vault.is_owned_by(&pinocchio_system::ID) {
return Err(ProgramError::InvalidAccountOwner);
}
// 3. Return the validated struct
Ok(Self { owner, vault })
}
}
What’s happening
- Destructure the input slice into named variables. If the caller supplied too few accounts, fail fast by returning a
NotEnoughAccountKeys
error. - Validate each constraint manually (signature, ownership, PDA seeds, data size… whatever the instruction requires).
- On success, return a typed struct; on failure, bubble up a precise
ProgramError
.
Instruction Data Validation with TryFrom
For instruction data, we follow the same pattern; validate first, then convert using TryFrom
:
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> {
// 1. Expect exactly eight bytes (one u64)
if data.len() != core::mem::size_of::<u64>() {
return Err(ProgramError::InvalidInstructionData);
}
// 2. Parse the bytes
let amount = u64::from_le_bytes(data.try_into().unwrap());
// 3. Basic sanity check
if amount == 0 {
return Err(ProgramError::InvalidInstructionData);
}
Ok(Self { amount })
}
}
What’s happening
- Confirm the byte slice matches the expected length.
- Convert the slice to a concrete type (
u64
). - Validate the value (non-zero in this case).
- Return
Ok(Self)
on success, or an appropriate error.
Using TryFrom
everywhere cleanly separates validation from business logic, improving maintainability and security.
Logic
Next, we combine the validated accounts and instruction data into a single handler struct:
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::try_from(data)?;
Ok(Self {
accounts,
instruction_datas,
})
}
}
This wrapper:
- Accepts both raw inputs (bytes and accounts).
- Delegates validation to the individual
TryFrom
implementations. - Returns a fully-typed, fully-checked Deposit struct.
Now the actual logic is concise:
impl<'a> Deposit<'a> {
pub const DISCRIMINATOR: &'a u8 = &0;
pub fn process(&self) -> ProgramResult {
Transfer {
from: self.accounts.owner,
to: self.accounts.vault,
lamports: self.instruction_datas.amount,
}
.invoke()?;
Ok(())
}
}
DISCRIMINATOR
is the byte we pattern-match on in entrypoint.process()
contains only business logic; all checks are already complete.
The result? Anchor-style ergonomics, but with all the benefits of being fully native; explicit, predictable, and fast.
Cross-Program Invocations (CPIs)
As mentioned, Pinocchio has some helper crates like pinocchio-system
and pinocchio-token
that make it super easy to perform Cross-Program Invocations (CPIs) to these native programs.
These helper structs and methods replace Anchor's CpiContext
approach we used previously:
Transfer {
from: self.accounts.owner,
to: self.accounts.vault,
lamports: self.instruction_datas.amount,
}
.invoke()?;
The Transfer
struct (from pinocchio-system
) packages every field the System Program needs, and .invoke()
fires the CPI. No context builder, no extra boilerplate.
When the caller must be a Program-Derived Address (PDA), Pinocchio keeps the API equally concise:
let seeds = [
Seed::from(b"vault"),
Seed::from(self.accounts.owner.key().as_ref()),
Seed::from(&[bump]),
];
let signers = [Signer::from(&seeds)];
Transfer {
from: self.accounts.vault,
to: self.accounts.owner,
lamports: self.accounts.vault.lamports(),
}
.invoke_signed(&signers)?;
Here's what happens:
Seeds
builds an array of Seed objects that match the PDA derivation.Signer
wraps those seeds in a Signer helper.invoke_signed
calls the CPI, passing the signer array so the PDA can authorize the transfer.
The result? a clean, first-class interface for both regular and signed CPIs; no macros required, and no hidden magic.