Skip to content

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.

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 front

We 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 type

The 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 owner decides who may write it; trusting data from 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:

solminiReal AnchorWhat it is
CounterState struct in data#[account] structthe typed state buffer (Anchor adds Borsh + an 8-byte discriminator)
accounts: &mut [Account]Context<T> / ctx.accountsthe accounts this instruction operates on
Program::processa handler in your #[program] modulethe stateless instruction logic
Program::id()declare_id!(...)the program’s on-chain address
the by-hand key/owner/signer checks#[account(...)] constraintsthe 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.

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.
  • 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.
  1. Write the one-line signature that captures the entire Solana programming model, and explain what each of its three parts (accounts, data, the Result) is.
  2. What does it mean to say a program is “stateless,” and how does the Program: Send + Sync bound in solmini connect that statelessness to the parallel scheduler?
  3. The transfer program mutates lamports directly, but the counter program serializes a struct into and out of account.data. Why two mechanisms — what is each appropriate for?
  4. Define “typed account data.” Who decides the type of the bytes in an account’s data buffer, and why is the runtime’s inability to read those bytes both a feature and a hazard?
  5. 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
  1. fn process(accounts: &mut [Account], data: &[u8]) -> Result<()>. accounts is the ordered list of accounts the transaction handed the instruction (the only mutable state the program touches); data is the opaque instruction blob the program interprets privately (which sub-instruction, with what arguments); the Result is success or failure, and on failure the whole transaction rolls back with nothing committed.
  2. “Stateless” means the program code holds no data of its own between calls — in solmini the programs are zero-sized unit structs, so all mutable state lives in the accounts passed in. Send + Sync is 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.
  3. lamports is the native balance the runtime understands directly, so value transfer uses it (the System program’s job). data is for program-specific typed state the runtime knows nothing about (the counter’s u64), which the program must serialize in and out itself — appropriate for any state beyond the native balance.
  4. “Typed account data” means an account’s data is 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).
  5. 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.