Stateless Programs Act on Accounts
The previous page, Everything Is an Account, established the data side of the model: an account is a balance, an opaque data buffer, and an owner. That leaves an obvious gap. If state lives in accounts, where does the code live, and how does it get its hands on the accounts it needs to change?
This page answers that. The short version is the whole thesis of the part: a program holds no state of its own. It is pure code. It receives the accounts it will operate on as an ordered list, plus a blob of instruction data, mutates those accounts, and returns. That separation — code here, state over there in named external accounts — is exactly the precondition that later lets the runtime declare a transaction’s footprint up front and run non-conflicting transactions in parallel. So this page is not a detour into “how to write a program”; it is where the book’s throughline — how do you build a single global state machine that runs at hardware speed? — gets its second load-bearing piece.
One signature is the entire model
Section titled “One signature is the entire model”Strip away every framework, macro, and convenience, and a Solana program is one function:
fn process(accounts: &mut [Account], data: &[u8]) -> Result<()>That is it. Read that signature slowly, because everything else on this page is a consequence of it:
accounts— the accounts this instruction is allowed to touch, handed in as an ordered list of buffers. The program does not go fetch accounts from a global store; it receives exactly the ones the transaction named, in the order the transaction named them.data— an opaque byte blob: the instruction. The runtime does not interpret it. It is the program’s private argument encoding — which sub-instruction to run, and with what parameters.-> Result<()>— success or failure. On failure, nothing the program did is committed; the transaction is rolled back whole. (We rely on that atomicity below.)
A program is stateless in the literal sense: the code carries no data between calls. In our companion
crate solmini the programs are zero-sized unit structs — there
is physically nowhere for per-program state to hide. The only mutable thing any program ever touches is
the accounts slice it was handed.
pub trait Program: Send + Sync { fn process(&self, accounts: &mut [Account], data: &[u8]) -> Result<()>;}The Send + Sync bound is not decoration. It is the compiler-checked promise that this code is safe to
share across threads — which the Sealevel scheduler later cashes in when it runs the same program on
many cores at once. A program holds no state, so sharing it is always safe; the marker traits let the
type system prove it. Statelessness is not just a style choice here; it is what makes the parallelism
sound.
Ethereum Solana ──────── ────── contract = code + its own storage program = code only (stateless) state hidden INSIDE the contract account = data + lamports (owned by a program) ──────────────────────── footprint unknown until you run it footprint DECLARED up frontWe contrast the two models in detail on Contract-Owned Storage vs External Accounts; the diagram above is the one fact to carry forward.
Example 1 — the transfer program mutates lamports
Section titled “Example 1 — the transfer program mutates lamports”The simplest possible program moves value between two accounts. It is Solana’s System-program transfer
in miniature: take the amount out of the instruction data, debit account 0, credit account 1.
/// Move lamports from accounts[0] to accounts[1]./// `data` is the amount as a little-endian u64.pub struct TransferProgram;
impl Program for TransferProgram { fn process(&self, accounts: &mut [Account], data: &[u8]) -> Result<()> { if accounts.len() != 2 { return Err(SolError::AccountCount { expected: 2, got: accounts.len() }); } let amount = read_u64(data)?; // instruction data → typed argument
let have = accounts[0].lamports; // [0] = from, [1] = to (positional, by convention) if have < amount { return Err(SolError::InsufficientFunds { have, need: amount }); } accounts[0].lamports = have - amount; accounts[1].lamports = accounts[1].lamports.saturating_add(amount); Ok(()) }}Two details are worth pausing on, because both recur everywhere.
The account roles are positional. Account 0 is the source and account 1 is the destination — not
because the runtime says so, but because this program decided so. The ordered list is a contract
between the transaction author and the program: they must agree on what each slot means. Nothing labels
accounts[0] “from” except the program’s own convention.
The check happens before the mutation. The balance is validated before a single lamport moves. If
the source is short, the function returns Err having changed nothing — and because a failed
transaction is never committed (its working copy is simply discarded), you get atomic rollback for
free. A transfer either happens completely or not at all. That ordering discipline — validate, then
mutate — is a habit every Solana program needs.
Example 2 — the counter program serializes typed state
Section titled “Example 2 — the counter program serializes typed state”The transfer program only touched lamports, which the runtime understands natively. But most programs
need state the runtime knows nothing about — a counter, a token balance, a game board. That state
lives in an account’s data buffer, and here we meet the idea that gives the page its title.
The counter program keeps a single u64 in one account’s data. To do that it defines a Rust type,
serializes it out of the buffer at the start of the instruction, mutates it, and serializes it back
in:
/// The typed state the counter keeps in its account's data buffer./// This is the analogue of an Anchor #[account] struct.#[derive(Serialize, Deserialize, Default)]pub struct CounterState { pub count: u64 }
impl CounterState { pub fn load(data: &[u8]) -> Result<Self> { // bytes → typed state if data.is_empty() { return Ok(Self::default()); } // fresh account reads as count: 0 Ok(serde_json::from_slice(data)?) } pub fn store(&self) -> Result<Vec<u8>> { // typed state → bytes Ok(serde_json::to_vec(self)?) }}
pub struct CounterProgram;
impl Program for CounterProgram { fn process(&self, accounts: &mut [Account], data: &[u8]) -> Result<()> { if accounts.len() != 1 { return Err(SolError::AccountCount { expected: 1, got: accounts.len() }); } let step = if data.is_empty() { 1 } else { read_u64(data)? };
let mut state = CounterState::load(&accounts[0].data)?; // deserialize the typed buffer state.count = state.count.saturating_add(step); accounts[0].data = state.store()?; // reserialize it back Ok(()) }}cargo test program in solmini exercises both programs: the transfer rejects an overdraft without
mutating, and the counter round-trips its typed state through three default increments to count = 3,
then a step of 10 to count = 13.
Typed account data — bytes the runtime cannot read
Section titled “Typed account data — bytes the runtime cannot read”Here is the crux. To the runtime, accounts[0].data is just Vec<u8> — a bag of bytes with no meaning.
The runtime never looks inside it. The only thing that knows those bytes are a CounterState is the
counter program itself. This is what “typed account data” means:
account.data: [ 7b 22 63 6f 75 6e 74 22 3a 33 7d ] ← opaque bytes to the runtime
runtime's view: "some buffer, 11 bytes, owned by the counter program" program's view: CounterState { count: 3 } ← the program supplies the typeThe program decides the type. Two different programs could store completely different structures in buffers of the same length, and the runtime would treat both identically — as opaque bytes owned by whoever owns the account. The type is not a property of the account; it is a convention held by the code that owns it. That is a sharp double edge, and the next section is where it bites.
Because you get a list of buffers, you must validate every one
Section titled “Because you get a list of buffers, you must validate every one”Pull the lesson out of the incident and state it as a rule. A Solana program cannot assume anything about the accounts it is handed except their position in the list. It must check, for each one that matters:
- Key — is this the specific account I expected (e.g. the canonical config account), not some look-alike an attacker constructed?
- Owner — is this account owned by the program I think owns it? An account’s
ownerdecides who may write it; trustingdatafrom an unexpected owner is how spoofing gets in. - Signer — did the party who is supposed to authorize this action actually sign the transaction?
The runtime enforces the big invariant for you — only an account’s owner program may mutate its
data or debit its lamports. But it will not tell you that the account in slot 3 is the right one.
That semantic check is code you write. In raw Solana you write it by hand; in Anchor you declare it, which
is the whole reason the framework exists.
The bridge: this is your Anchor mental model
Section titled “The bridge: this is your Anchor mental model”If you have written an Anchor program, everything above is already familiar — Anchor is this same skeleton with the serialization glue and the validation checks generated for you. Map the pieces directly:
solmini | Real Anchor | What it is |
|---|---|---|
CounterState struct in data | #[account] struct | the typed state buffer (Anchor adds Borsh + an 8-byte discriminator) |
accounts: &mut [Account] | Context<T> / ctx.accounts | the accounts this instruction operates on |
Program::process | a handler in your #[program] module | the stateless instruction logic |
Program::id() | declare_id!(...) | the program’s on-chain address |
| the by-hand key/owner/signer checks | #[account(...)] constraints | the validation the Wormhole hack skipped |
The #[derive(Accounts)] struct you write at the top of every Anchor instruction — the one listing each
account and tagging it mut, Signer, has_one, seeds — is both the up-front footprint declaration
the runtime needs for parallelism and the account validation the account model demands. Anchor folds
the two jobs into one declaration. That is why it feels like boilerplate and is in fact load-bearing.
Under the hood — what real Anchor adds
Section titled “Under the hood — what real Anchor adds”Our CounterState uses serde_json for readability. Real Anchor differs in two ways that matter:
- Borsh, not JSON. Anchor serializes with Borsh, a compact deterministic binary format — smaller buffers and no ambiguity about byte layout.
- The 8-byte discriminator. Anchor prepends an 8-byte discriminator to every account’s data — a
hash of the account’s type name. Before a program trusts a buffer as a
CounterState, it checks that the first eight bytes match. This is a type tag stored in the bytes, and it is exactly the defense against handing a program the wrong account that our model skips. It is Borsh’s structural answer to the account-confusion class the Wormhole hack belongs to.
The architect’s lens
Section titled “The architect’s lens”- Why does it exist? Because separating code from state is the precondition for everything Solana is fast at. A program is stateless so that all mutable state lives in named external accounts a transaction can list before it runs — the fact the parallel scheduler depends on.
- What problem does it solve? It makes a transaction’s state footprint knowable in advance. When code carries no hidden storage, the accounts a transaction touches are exactly the ones it declares, so the runtime can prove which transactions are disjoint and run them at once.
- What are the trade-offs? You gain parallelism and a small, uniform runtime; you pay by doing your own account validation (key/owner/signer) and your own serialization of typed state — work an Ethereum contract, with storage built in, never faces.
- When should I avoid it? You do not avoid it on Solana — it is the model. But the discipline it demands (never trust an account you were handed) is not optional: skip it and you have written a Wormhole. Reach for Anchor’s declarative constraints rather than hand-rolling checks whenever you can.
- What breaks if I remove it? Fold state back inside the code — the Ethereum design — and footprints become unknowable until runtime, which collapses the schedule back to one-transaction-at-a-time. Remove statelessness and you remove the parallelism; the whole throughput bet unwinds.
Check your understanding
Section titled “Check your understanding”- Write the one-line signature that captures the entire Solana programming model, and explain what each
of its three parts (
accounts,data, theResult) is. - What does it mean to say a program is “stateless,” and how does the
Program: Send + Syncbound insolminiconnect that statelessness to the parallel scheduler? - The transfer program mutates
lamportsdirectly, but the counter program serializes a struct into and out ofaccount.data. Why two mechanisms — what is each appropriate for? - Define “typed account data.” Who decides the type of the bytes in an account’s
databuffer, and why is the runtime’s inability to read those bytes both a feature and a hazard? - A program receives its accounts as an ordered list of buffers. What must it validate about each account it is handed, and what real, dated incident shows the cost of skipping that validation?
Show answers
fn process(accounts: &mut [Account], data: &[u8]) -> Result<()>.accountsis the ordered list of accounts the transaction handed the instruction (the only mutable state the program touches);datais the opaque instruction blob the program interprets privately (which sub-instruction, with what arguments); theResultis success or failure, and on failure the whole transaction rolls back with nothing committed.- “Stateless” means the program code holds no data of its own between calls — in
solminithe programs are zero-sized unit structs, so all mutable state lives in theaccountspassed in.Send + Syncis the compiler-checked proof that the code is safe to share across threads; because a stateless program has no mutable state to race over, the type system can prove it, which is what lets the Sealevel scheduler run the same program on many cores at once. lamportsis the native balance the runtime understands directly, so value transfer uses it (the System program’s job).datais for program-specific typed state the runtime knows nothing about (the counter’su64), which the program must serialize in and out itself — appropriate for any state beyond the native balance.- “Typed account data” means an account’s
datais opaque bytes to the runtime and meaningful only to the program that owns it — the program decides the type it deserializes those bytes into. It is a feature because it keeps the runtime tiny and uniform (it never has to understand application state); it is a hazard because nothing but the program’s own checks stops a different buffer from being handed in and misinterpreted (account confusion). - It must validate at least the key (is this the specific account I expected?), the owner (is it
owned by the program I think owns it?), and, where authorization matters, the signer (did the
right party sign?). The Wormhole hack of 2 February 2022 (~$320M) is the failure mode: the program
accepted a spoofed “instructions sysvar” account without confirming it was genuine, bypassing the
guardian-signature check and minting unbacked wrapped ETH. Anchor’s
#[account(...)]constraints exist to make these checks declarative and hard to forget.