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.
The runtime calls exactly one function
Section titled “The runtime calls exactly one function”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.
The entrypoint! macro: the ABI glue
Section titled “The entrypoint! macro: the ABI glue”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 LoaderSo “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.
AccountInfo: the real, richer Account
Section titled “AccountInfo: the real, richer Account”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 Account | AccountInfo | why the real one is richer |
|---|---|---|
lamports: u64 | lamports: 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: Pubkey | owner: &Pubkey | who may write data/debit lamports |
| — | key: &Pubkey | the account’s own address, for identity checks |
| — | is_signer | authorization: did the key sign? |
| — | is_writable | the footprint flag the scheduler read |
| — | executable | is 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.
Why lamports and data are RefCells
Section titled “Why lamports and data are RefCells”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 severalAccountInfohandles (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 aResult; 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 CPIThe 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 byte01means “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.
solminiusedserde_jsonfor 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.
Reading and writing account data by hand
Section titled “Reading and writing account data by hand”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.
The architect’s lens
Section titled “The architect’s lens”- Why does it exist? Because the runtime needs exactly one documented way to hand a program its
inputs and get its output.
entrypoint!+process_instructionis 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
unsafepointer 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.
Check your understanding
Section titled “Check your understanding”- Write the canonical
process_instructionsignature, and say what each ofprogram_id,accounts, andinstruction_datais. Which one has no equivalent insolmini’sprocess, and why do you need it? - What does the
entrypoint!macro actually do, and why can’t a real program callprocess_instructionthe waysolmini’s runtime calledprocessdirectly? AccountInfo’slamportsanddataareRc<RefCell<&mut ...>>rather than plain fields. Explain theRcand theRefCellseparately, and give the concrete situation that makes a static&mutinsufficient.- Describe instruction dispatch in a native program: what do the first byte(s) of
instruction_dataselect, how do you get typed arguments out of the rest, and why must the_ =>arm return an error? - 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
fn process_instruction(program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8]) -> ProgramResult.program_idis the program’s own on-chain id;accountsis the ordered list of accounts the transaction named (as richAccountInfos);instruction_datais the opaque instruction blob the program interprets privately.program_idhas nosolminiequivalent — 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.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;solminihad no boundary because it calledprocessin-process.Rcgives shared ownership so severalAccountInfohandles (plus the runtime and any CPI) can point at the same lamports/data.RefCellmoves the borrow check to runtime (try_borrow/try_borrow_mutreturn aResult), permitting “shared, single-writer, checked dynamically.” A static&mutis 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.- The leading byte(s) are a discriminant/tag that selects which handler to run; you
split_firstit off andmatchon it. The remaining bytes are that instruction’s serialized arguments, which you Borsh-try_from_sliceinto 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. - When you deploy, the compiled SBF bytecode is written into an account’s
databuffer; that account’sexecutableflag is set totrueand itsowneris a loader (e.g. the Upgradeable BPF Loader). A transaction names that account id as the instruction’s program; the runtime seesexecutable: true, asks the loader to verify/load the bytecode, and calls theentrypoint!entry.solminiinstead kept programs in an in-processHashMapregistry and looked them up by id — no account, no loader; the real system stores code as just another account.