Skip to content

The Native Program: entrypoint and process_instruction

The part overview promised a descent: from the solmini runtime model you built, down to a real Solana program compiled against the actual SDK. This page is the first rung. We take solmini’s one-line thesis — fn process(accounts: &mut [Account], data: &[u8]) -> Result<()> — and make it real, with no Anchor, no macros hiding the mechanism. Just the raw entrypoint the Solana runtime calls.

Everything here is the same skeleton you already know from Stateless Programs Act on Accounts, fleshed out against the solana-program crate. By the end you will be able to read a native (frameworkless) program top to bottom and name the runtime contract behind every line — because that contract is the book’s throughline: state in external accounts, footprints declared up front, so a scheduler can run non-conflicting transactions in parallel.

A deployed Solana program is a blob of bytecode with a single documented entry point. The runtime does not poke at your functions by name, does not construct objects, does not hold a Program trait object the way solmini’s registry does. It loads your bytecode and calls one function with three arguments. That function is process_instruction, and its canonical signature is the whole native programming model:

use solana_program::{
account_info::AccountInfo,
entrypoint,
entrypoint::ProgramResult,
pubkey::Pubkey,
};
entrypoint!(process_instruction);
pub fn process_instruction(
program_id: &Pubkey, // which program is this? (my own id)
accounts: &[AccountInfo], // the accounts the tx named, in order
instruction_data: &[u8], // the opaque instruction blob
) -> ProgramResult {
Ok(())
}

Set that beside solmini’s trait method and the mapping is nearly one-to-one:

solmini real Solana (solana-program)
─────── ────────────────────────────
fn process(&self, fn process_instruction(
accounts: &mut [Account], program_id: &Pubkey, ← new: your own id
data: &[u8]) -> Result<()> accounts: &[AccountInfo], ← richer Account
instruction_data: &[u8]
) -> ProgramResult ← Result<(), ProgramError>

Two differences are worth naming now.

program_id is new. solmini never passed a program its own id, because the registry already knew which program it was dispatching to. On real Solana the runtime hands you your own id every call. You need it to derive program-owned addresses (PDAs) and to check that accounts you expect to own are in fact owned by you — both of which the PDAs and CPI page builds on.

accounts is &[AccountInfo], not &mut [Account]. This looks like a downgrade — the slice is not mut — but it is the opposite. AccountInfo is a far richer, more carefully guarded structure than solmini’s plain Account, and its interior mutability is what the next section is about.

solmini never needed an entrypoint macro because it called process directly in-process, in Rust. Real programs run inside the Solana VM (the SBF/eBPF sandbox), and the runtime speaks to them across a lower-level boundary: it hands the program a single flat byte buffer containing the serialized program_id, all the account metadata and data, and the instruction bytes, packed in a fixed layout.

entrypoint!(process_instruction) generates the unsafe glue that bridges that boundary:

runtime ──► one packed input buffer ──► [ entrypoint! generated code ] ──► your process_instruction
(program_id ‖ accounts ‖ data) deserializes the buffer (program_id, accounts, data)
into (&Pubkey, &[AccountInfo], &[u8])

The macro expands to a function with a raw C-style signature the loader knows how to call, deserializes the input region into the three friendly Rust arguments, invokes your handler, and serializes the account changes back so the runtime can commit them. You write safe Rust against (program_id, accounts, instruction_data); the macro absorbs the unsafe pointer arithmetic. This is the exact seam the loader and verifier page describes from the runtime’s side — here we meet it from the program’s side.

Under the hood — programs are executable accounts, not registry entries

Section titled “Under the hood — programs are executable accounts, not registry entries”

solmini took a shortcut we should now be honest about: it kept programs in an in-memory registry, a HashMap<Pubkey, Box<dyn Program>>, and dispatched by looking the id up. Real Solana has no such registry. Instead — and this is the account model applied to code itself — a program is an account.

When you solana program deploy target/deploy/myprog.so, the toolchain writes your compiled SBF bytecode into an account’s data buffer and flips that account’s executable flag to true. The account’s owner is a loader program (e.g. the Upgradeable BPF Loader). To invoke your program, a transaction names its account id as the instruction’s program; the runtime sees executable: true, asks the loader to verify and load the bytecode, and calls the entrypoint!-generated entry.

solmini: registry[program_id] -> &dyn Program (code lives in the host process)
Solana: account[program_id].data = SBF bytecode (code lives in an account, like all state)
account[program_id].executable = true
account[program_id].owner = BPF Loader

So “everything is an account” is not almost-true with an asterisk for code. Code is an account too — a buffer of bytes the runtime happens to execute instead of interpret. There is no separate namespace for programs; the loader, not a registry, is what turns those bytes into something callable.

solmini’s Account was three fields: lamports, data, owner. AccountInfo is the production version, and it carries the fields you actually need to validate an account (the checks the Wormhole hack punished skipping) and to mutate it safely under the borrow checker:

pub struct AccountInfo<'a> {
pub key: &'a Pubkey, // the account's address
pub lamports: Rc<RefCell<&'a mut u64>>, // native balance — borrow-checked
pub data: Rc<RefCell<&'a mut [u8]>>, // the data buffer — borrow-checked
pub owner: &'a Pubkey, // program allowed to write this account
pub is_signer: bool, // did this account's key sign the tx?
pub is_writable: bool, // was it declared writable in the footprint?
pub executable: bool, // is this account a program?
pub rent_epoch: Epoch, // rent bookkeeping
}

Line the two up:

solmini AccountAccountInfowhy the real one is richer
lamports: u64lamports: Rc<RefCell<&mut u64>>interior mutability behind a runtime borrow check
data: Vec<u8>data: Rc<RefCell<&mut [u8]>>same — a fixed-length buffer you borrow, not own
owner: Pubkeyowner: &Pubkeywho may write data/debit lamports
key: &Pubkeythe account’s own address, for identity checks
is_signerauthorization: did the key sign?
is_writablethe footprint flag the scheduler read
executableis this account a program?

The last three — is_signer, is_writable, executable — are exactly the instruction and account flags the transaction author set. The runtime copies them into AccountInfo so your program can enforce them. Reading is_signer is how you answer “did the right party authorize this?”; the next page, Parsing Accounts and the Checks You Must Do by Hand, turns each flag into an explicit guard.

This is the detail people trip on, so slow down. In solmini we took accounts: &mut [Account] — one exclusive mutable borrow of the whole slice — so the Rust compiler statically guaranteed no aliasing. Real Solana cannot do that, for a concrete reason: the same account can appear in the accounts slice more than once, and — through cross-program invocation — two AccountInfos can point at the same underlying lamports and data. A single compile-time &mut cannot express “shared, but only one writer at a time, checked dynamically.” That is precisely what Rc<RefCell<...>> is for.

  • Rc — shared ownership, so several AccountInfo handles (and the runtime, and any CPI) can all hold a reference to the same lamports/data.
  • RefCell — moves the borrow check from compile time to run time. try_borrow() / try_borrow_mut() return a Result; if you already hold a conflicting borrow, you get an error instead of undefined behavior.
solmini: &mut [Account] → compiler proves no aliasing (static, whole-slice)
Solana: Rc<RefCell<&mut [u8]>> → runtime proves no aliasing (dynamic, per-borrow)
because one account can alias itself across the slice / across a CPI

The trade is deliberate. Static borrow checking is stronger but cannot model an account aliasing itself through a cross-program call decided at runtime. RefCell accepts a small runtime cost and a try_borrow_mut that can fail, in exchange for expressing the sharing the account model actually requires. It is the same “shared XOR mutable” rule from page one of the runtime part — enforced dynamically because the aliasing is dynamic.

Instruction dispatch: match on the first byte(s)

Section titled “Instruction dispatch: match on the first byte(s)”

solmini’s two programs each did one thing, so data was just an argument (a u64). Real programs do many things — initialize, increment, close, transfer authority — and they all arrive through the same process_instruction. So the very first job of a native program is to look at instruction_data and decide which handler to run. The universal convention: the leading byte(s) select the instruction; the rest is that instruction’s serialized arguments.

instruction_data: [ 01 | 0a 00 00 00 00 00 00 00 ]
^^ ^^^^^^^^^^^^^^^^^^^^^^^^^
tag the args for handler 01 (here: a u64 = 10, little-endian / Borsh)

Split the tag off the front, match on it, and deserialize the remainder into a typed argument struct:

use borsh::{BorshDeserialize, BorshSerialize};
use solana_program::program_error::ProgramError;
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
// Peel the discriminant off the front of the blob.
let (tag, rest) = instruction_data
.split_first()
.ok_or(ProgramError::InvalidInstructionData)?;
match tag {
0 => process_initialize(program_id, accounts),
1 => {
// Deserialize the *rest* of the blob into typed args (Borsh).
let args = IncrementArgs::try_from_slice(rest)
.map_err(|_| ProgramError::InvalidInstructionData)?;
process_increment(program_id, accounts, args.step)
}
_ => Err(ProgramError::InvalidInstructionData), // unknown instruction
}
}
#[derive(BorshSerialize, BorshDeserialize)]
pub struct IncrementArgs {
pub step: u64,
}

Three things to notice, because they recur in every native program:

  • The tag is a private convention, exactly like solmini’s positional account roles. The runtime does not know byte 01 means “increment”; your program decided that, and the client that builds the instruction must agree. Get the encoding wrong on either side and you dispatch to the wrong handler — or, worse, deserialize garbage into a valid-looking struct.
  • Borsh, not JSON. solmini used serde_json for readability; real Solana uses Borsh — a compact, deterministic binary format with no whitespace, no field-name ambiguity, and a fixed byte layout. Two clients serializing the same struct get identical bytes, which matters when those bytes are hashed, signed, and replayed across a global network.
  • Unknown tags must fail closed. The _ => arm returns an error rather than silently succeeding. A program that accepts instructions it does not understand is a program that will one day do something its author never wrote.

This is what Anchor’s #[program] macro generates for you: it hashes each handler’s name into an 8-byte discriminator, prepends it to the instruction, and produces exactly this split_first + match + Borsh try_from_slice dispatch under the hood. The Anchor page shows the same skeleton with the macro turned on. Here you are writing the dispatcher Anchor would have written.

Once dispatch picks a handler, the handler does what every Solana program ultimately does: read some accounts, mutate some, done. Native programs do this through AccountInfo’s RefCells and by (de)serializing the byte buffer themselves — the raw version of solmini’s CounterState::load / store. Here is the increment handler in full:

use solana_program::account_info::next_account_info;
#[derive(BorshSerialize, BorshDeserialize, Default)]
pub struct Counter {
pub count: u64,
}
fn process_increment(
program_id: &Pubkey,
accounts: &[AccountInfo],
step: u64,
) -> ProgramResult {
let account_iter = &mut accounts.iter();
let counter_ai = next_account_info(account_iter)?; // accounts[0], by convention
// Validation the runtime will NOT do for you (full treatment on the next page):
if counter_ai.owner != program_id {
return Err(ProgramError::IllegalOwner); // only *our* account may hold *our* state
}
if !counter_ai.is_writable {
return Err(ProgramError::InvalidAccountData); // we intend to mutate it
}
// Read: borrow the buffer, deserialize the typed state out of it.
let mut counter = {
let bytes = counter_ai.try_borrow_data()?; // dynamic borrow — can fail if aliased
Counter::try_from_slice(&bytes)
.map_err(|_| ProgramError::InvalidAccountData)?
}; // the borrow is dropped here, before we take a mutable one
counter.count = counter.count.checked_add(step)
.ok_or(ProgramError::InvalidInstructionData)?; // no silent overflow
// Write: borrow mutably, serialize the state back into the same buffer.
let mut bytes = counter_ai.try_borrow_mut_data()?;
counter.serialize(&mut &mut bytes[..])
.map_err(|_| ProgramError::AccountDataTooSmall)?;
Ok(())
}

Match this against solmini’s counter and the structure is identical — load, mutate, store — with two real-world sharpenings:

You borrow the buffer; you do not own it. In solmini, accounts[0].data = state.store()? replaced the Vec<u8>. Real account data is a fixed-length slice you borrow (try_borrow_mut_data) and write into. You cannot grow it mid-instruction — the size was set at account creation. If your serialized state is larger than the buffer, serialize errors (AccountDataTooSmall); the space math is a design-time decision the runtime part ties to rent.

Borrows are scoped on purpose. The read borrow lives in its own { ... } block so it is dropped before the mutable borrow is taken. Because these are RefCells, holding a live read borrow while calling try_borrow_mut_data would return a BorrowMutError at runtime — not a compile error. Scoping the borrows tightly is how you keep the dynamic check happy. This is the price of the interior mutability that lets an account alias itself; pay it by keeping every borrow short.

  • Why does it exist? Because the runtime needs exactly one documented way to hand a program its inputs and get its output. entrypoint! + process_instruction is that ABI: a single function, three arguments, one packed buffer in and committed account changes out.
  • What problem does it solve? It bridges the raw VM boundary — a flat serialized byte region — to safe Rust you can reason about, and it gives multi-instruction programs a place to dispatch. Without it you would be doing unsafe pointer arithmetic on the input buffer in every program.
  • What are the trade-offs? You get a tiny, uniform runtime contract; you pay by writing your own dispatch, your own Borsh (de)serialization, and your own account validation — and by living with RefCells that push borrow errors to runtime. Anchor exists to generate most of that back for you.
  • When should I avoid it? Avoid hand-writing the raw entrypoint when a framework will do it more safely — most production programs use Anchor precisely so the dispatch and checks are generated and hard to get wrong. Reach for the native entrypoint when you need maximum control, minimal dependencies, or the smallest possible bytecode.
  • What breaks if I remove it? Remove the entrypoint and the loader has nothing to call — your bytecode is inert. Remove the dispatch-on-tag convention and a program can do only one thing. Remove the by-hand validation and you have written the Wormhole hack again.
  1. Write the canonical process_instruction signature, and say what each of program_id, accounts, and instruction_data is. Which one has no equivalent in solmini’s process, and why do you need it?
  2. What does the entrypoint! macro actually do, and why can’t a real program call process_instruction the way solmini’s runtime called process directly?
  3. AccountInfo’s lamports and data are Rc<RefCell<&mut ...>> rather than plain fields. Explain the Rc and the RefCell separately, and give the concrete situation that makes a static &mut insufficient.
  4. Describe instruction dispatch in a native program: what do the first byte(s) of instruction_data select, how do you get typed arguments out of the rest, and why must the _ => arm return an error?
  5. A program is “an executable account.” Unpack that: where does the compiled bytecode live, which flag and which owner make it callable, and how does this differ from solmini’s program registry?
Show answers
  1. fn process_instruction(program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8]) -> ProgramResult. program_id is the program’s own on-chain id; accounts is the ordered list of accounts the transaction named (as rich AccountInfos); instruction_data is the opaque instruction blob the program interprets privately. program_id has no solmini equivalent — the registry already knew which program it dispatched to — and you need it to derive/verify program-owned addresses (PDAs) and to check that accounts holding your state are actually owned by you.
  2. entrypoint! generates the unsafe C-ABI glue the loader calls: it deserializes the single packed input buffer (program_id + account metadata/data + instruction bytes) into the three safe Rust arguments, invokes your handler, and serializes account changes back. A real program runs inside the SBF/eBPF sandbox and receives its input as one flat byte region across the VM boundary, not as live Rust values — so something must translate the boundary; solmini had no boundary because it called process in-process.
  3. Rc gives shared ownership so several AccountInfo handles (plus the runtime and any CPI) can point at the same lamports/data. RefCell moves the borrow check to runtime (try_borrow / try_borrow_mut return a Result), permitting “shared, single-writer, checked dynamically.” A static &mut is insufficient because the same account can appear twice in the slice or be aliased across a cross-program invocation — aliasing decided at runtime that a compile-time exclusive borrow cannot express.
  4. The leading byte(s) are a discriminant/tag that selects which handler to run; you split_first it off and match on it. The remaining bytes are that instruction’s serialized arguments, which you Borsh-try_from_slice into a typed args struct. The _ => arm must return an error so unknown instructions fail closed — a program that silently accepts instructions it does not understand will eventually perform an action its author never wrote.
  5. When you deploy, the compiled SBF bytecode is written into an account’s data buffer; that account’s executable flag is set to true and its owner is a loader (e.g. the Upgradeable BPF Loader). A transaction names that account id as the instruction’s program; the runtime sees executable: true, asks the loader to verify/load the bytecode, and calls the entrypoint! entry. solmini instead kept programs in an in-process HashMap registry and looked them up by id — no account, no loader; the real system stores code as just another account.