Instructions
As we saw earlier, using the TryFrom
trait allows us to cleanly separate validation from business logic, improving both maintainability and security.
Instruction Structure
When it's time to process the logic, we can create a structure like this:
pub struct Deposit<'a> {
pub accounts: DepositAccounts<'a>,
pub instruction_datas: DepositInstructionData,
}
This structure defines what data will be accessible during the logic processing. We then deserialize this using the try_from
function that you can find in the lib.rs
file:
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 provides three key benefits:
- It accepts both raw inputs (bytes and accounts)
- It delegates validation to the individual
TryFrom
implementations - It returns a fully-typed, fully-validated Deposit struct
We can then implement the processing logic like this:
impl<'a> Deposit<'a> {
pub const DISCRIMINATOR: &'a u8 = &0;
pub fn process(&self) -> ProgramResult {
// deposit logic
Ok(())
}
}
- The
DISCRIMINATOR
is the byte we use for pattern matching in the entrypoint - The
process()
method contains only business logic, as all validation checks are already complete
The result? We get Anchor-style ergonomics with all the benefits of being fully native: explicit, predictable, and fast.
Cross Program Invocation
As mentioned earlier, Pinocchio provides helper crates like pinocchio-system
and pinocchio-token
that simplify Cross-Program Invocations (CPIs) to 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
) encapsulates all the fields required by the System Program, and .invoke()
executes the CPI. No context builder or extra boilerplate needed.
When the caller must be a Program-Derived Address (PDA), Pinocchio maintains the same concise API:
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 how it works:
Seeds
creates an array of Seed objects that match the PDA derivationSigner
wraps these seeds in a Signer helperinvoke_signed
executes the CPI, passing the signer array to authorize the transfer
The result? A clean, first-class interface for both regular and signed CPIs: no macros required, and no hidden magic.
Multiple Instruction Structure
Often, you’ll want to reuse the same account structure and validation logic across several instructions; such as when updating different configuration fields.
Instead of creating a separate discriminator for each instruction, you can use a pattern that distinguishes instructions by the size of their data payload.
Here’s how it works:
We use a single instruction discriminator for all related config updates. The specific instruction is determined by the length of the incoming data.
After that, in your processor, match on self.data.len()
. Each instruction type has a unique data size, so you can dispatch to the correct handler accordingly.
It will look like this:
pub struct UpdateConfig<'a> {
pub accounts: UpdateConfigAccounts<'a>,
pub data: &'a [u8],
}
impl<'a> TryFrom<(&'a [u8], &'a [AccountInfo])> for UpdateConfig<'a> {
type Error = ProgramError;
fn try_from((data, accounts): (&'a [u8], &'a [AccountInfo])) -> Result<Self, Self::Error> {
let accounts = UpdateConfigAccounts::try_from(accounts)?;
// Return the initialized struct
Ok(Self { accounts, data })
}
}
impl<'a> UpdateConfig<'a> {
pub const DISCRIMINATOR: &'a u8 = &4;
pub fn process(&mut self) -> ProgramResult {
match self.data.len() {
len if len == size_of::<UpdateConfigStatusInstructionData>() => {
self.process_update_status()
}
len if len == size_of::<UpdateConfigFeeInstructionData>() => self.process_update_fee(),
len if len == size_of::<UpdateConfigAuthorityInstructionData>() => {
self.process_update_authority()
}
_ => Err(ProgramError::InvalidInstructionData),
}
}
//..
}
Notice that we defer deserialization of the instruction data until after we know which handler to call. This avoids unnecessary parsing and keeps the entrypoint logic clean.
Each handler then can deserialize its specific data type and performs the update:
pub fn process_update_authority(&mut self) -> ProgramResult {
let instruction_data = UpdateConfigAuthorityInstructionData::try_from(self.data)?;
let mut data = self.accounts.config.try_borrow_mut_data()?;
let config = Config::load_mut_unchecked(&mut data)?;
unsafe { config.set_authority_unchecked(instruction_data.authority) }?;
Ok(())
}
pub fn process_update_fee(&mut self) -> ProgramResult {
let instruction_data = UpdateConfigFeeInstructionData::try_from(self.data)?;
let mut data = self.accounts.config.try_borrow_mut_data()?;
let config = Config::load_mut_unchecked(&mut data)?;
unsafe { config.set_fee_unchecked(instruction_data.fee) }?;
Ok(())
}
pub fn process_update_status(&mut self) -> ProgramResult {
let instruction_data = UpdateConfigStatusInstructionData::try_from(self.data)?;
let mut data = self.accounts.config.try_borrow_mut_data()?;
let config = Config::load_mut_unchecked(&mut data)?;
unsafe { config.set_state_unchecked(instruction_data.status) }?;
Ok(())
}
This approach lets you share account validation and use a single entrypoint for multiple related instructions, reducing boilerplate and making your codebase easier to maintain.
By pattern matching on data size, you can efficiently route to the correct logic without extra discriminators or complex parsing.