Rust
Pinocchio Vault

Pinocchio Vault

39 Graduates

Chapter 1: The Vault

Pinocchio 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:

  1. split_first() peels off the discriminator byte.
  2. match decides which instruction struct to instantiate.
  3. Each instruction’s try_from implementation validates and deserializes its inputs.
  4. 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

  1. Destructure the input slice into named variables. If the caller supplied too few accounts, fail fast by returning a NotEnoughAccountKeys error.
  2. Validate each constraint manually (signature, ownership, PDA seeds, data size… whatever the instruction requires).
  3. 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

  1. Confirm the byte slice matches the expected length.
  2. Convert the slice to a concrete type (u64).
  3. Validate the value (non-zero in this case).
  4. 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:

  1. Accepts both raw inputs (bytes and accounts).
  2. Delegates validation to the individual TryFrom implementations.
  3. 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:

  1. Seeds builds an array of Seed objects that match the PDA derivation.
  2. Signer wraps those seeds in a Signer helper.
  3. 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.

Contents
View Source
naro © 2025Commit: bd658da