Accounts and Stateless Programs: State Outside the Code
On the previous page you built a clock: a verifiable ordering of events that no node has to agree on by voting, because the hash chain is the order. That answers when things happen. This page answers what they happen to. The clock orders transactions; transactions read and write state. So we need a data model for state — and the model Solana chose is the quiet key to its whole throughput story.
Here is the throughline for this page. Recall the throughput problem: a chain that executes transactions one at a time is capped by a single core, no matter how many cores the machine has. To go faster you must run non-conflicting transactions in parallel — and to do that, the runtime has to know, before it runs a transaction, which pieces of state it will touch. That single requirement drives the entire design on this page. We will build accounts and stateless programs in Rust, and by the end the reason for every design choice will be the same sentence: so a transaction can declare its footprint up front.
The split that makes everything else possible
Section titled “The split that makes everything else possible”Start with the model most people already know: Ethereum. There, a smart contract is a single thing that bundles code and its own storage. State lives inside the contract, in a private key-value store only that contract’s code can reach. This is intuitive — the contract “has” its variables — but it has a hidden cost. You cannot know which storage slots a call will touch until you run the call, because the storage is the contract’s private business, reachable only through arbitrary code paths. And if you can’t know the footprint without running it, you can’t schedule two calls to run in parallel without risking a collision. So the safe thing is to run them one at a time.
Solana makes the opposite choice. It splits code and state into two different kinds of thing:
Ethereum Solana ──────── ────── contract account program account (executable code, STATELESS) = code + its own storage owns ▼ data accounts (typed buffers + lamports)
state hidden INSIDE the contract state in NAMED, EXTERNAL accounts → footprint unknown until run → footprint declared up frontA program is pure code that owns no state of its own. All mutable state lives in separate accounts the program owns — and the program is the only thing allowed to write those accounts’ data. Because state now lives in named, external accounts, a transaction can list the accounts it will touch before it runs. That list is exactly the information the scheduler needs to decide which transactions are safe to run at the same time. The account model is not just a different data layout; it is the precondition for parallel execution. Everything else on this page is detail underneath that one idea.
The Pubkey: a readable address
Section titled “The Pubkey: a readable address”Before accounts, we need a way to name them. In real Solana an address is a 32-byte ed25519 public key. For a runtime we build to learn from, we want addresses that are deterministic and readable in tests, so we derive a key from a human label by hashing it. Real crypto keys later; readable demos now.
use crate::poh::sha256;
/// A 32-byte public key — the address of an account or a program.////// Real Solana keys are ed25519 public keys; here we derive a deterministic key/// from a label by hashing it, which keeps tests readable: `Pubkey::from_seed("alice")`.#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]pub struct Pubkey([u8; 32]);
impl Pubkey { /// A deterministic key derived from a human label. pub fn from_seed(seed: &str) -> Self { Pubkey(sha256(seed.as_bytes())) }
/// A short hex form for logs and errors (first 4 bytes). pub fn short(&self) -> String { let b = &self.0; format!("{:02x}{:02x}{:02x}{:02x}…", b[0], b[1], b[2], b[3]) }}Two properties earn their keep. Hash + Eq lets a Pubkey be a HashMap key — that is how the runtime
will hold “the world” as a HashMap<Pubkey, Account>. And from_seed being a pure function of its label
means Pubkey::from_seed("alice") is the same key in every test, so a demo reads like a story instead of a
wall of hex.
The Account: balance, data, owner
Section titled “The Account: balance, data, owner”Now the account itself. In Solana, everything is an account — wallets, program state, even programs themselves. And an account is a strikingly small thing: a balance, a raw buffer of bytes, and an owner.
/// An account: a balance, an arbitrary data buffer, and the program that owns it.////// - `lamports` — the native balance (Solana's smallest unit)./// - `data` — a raw byte buffer a program interprets however it likes./// - `owner` — the program id permitted to mutate this account's `data`.#[derive(Clone, Debug, Default, PartialEq, Eq)]pub struct Account { pub lamports: u64, pub data: Vec<u8>, pub owner: Pubkey,}
impl Account { /// A plain System-owned wallet holding `lamports` and no data. pub fn wallet(lamports: u64) -> Self { Account { lamports, data: Vec::new(), owner: Pubkey::default() } }
/// An account preloaded with a serialized state buffer, owned by `owner`. pub fn with_data(lamports: u64, owner: Pubkey, data: Vec<u8>) -> Self { Account { lamports, data, owner } }}Look at what each field is for:
lamportsis the native balance, denominated in Solana’s smallest unit. This is the only state the runtime itself understands; everything else is opaque to it.datais a plainVec<u8>. The runtime has no idea what is in there — it is just bytes. Whatever program owns this account gets to decide how to interpret them. A counter puts a serializedu64there; a token account puts a balance and a mint; your program puts whatever struct it likes.owneris the program id allowed to mutatedata. This is the enforcement point of the whole model: a program may only write accounts it owns. The all-zero default owner stands in for the System program, which owns plain wallets.
The important mental shift: the account does not contain code. It is inert. It cannot do anything. Behavior lives entirely in programs, and programs are handed accounts to act on. State and code have been pulled apart, and the account is the “state” half.
The Program: stateless code over the accounts it is handed
Section titled “The Program: stateless code over the accounts it is handed”Here is the other half, and it is even smaller. A program is a pure function. It takes the accounts a
transaction handed it (in a fixed, declared order) plus an opaque data blob (the instruction), and it
mutates those accounts. That is the entire Solana programming model in one trait:
use crate::account::Account;use crate::error::Result;
/// A stateless on-chain program. Implementors are unit structs: the only state/// they ever touch is in the `accounts` passed to `process`.////// The `Send + Sync` bound lets the scheduler share one `&dyn Program` across/// worker threads — the program is immutable code, so sharing it is always safe,/// and the compiler's marker traits prove it.pub trait Program: Send + Sync { fn process(&self, accounts: &mut [Account], data: &[u8]) -> Result<()>;}Three things in this signature are load-bearing, and none is an accident.
&self, not &mut self. A program holds no state, so process never mutates the program — only the
accounts. Every program we write is a zero-sized unit struct. The only mutable thing in sight is
accounts: &mut [Account], the caller-supplied working set.
accounts: &mut [Account] — a slice, in a fixed order. The program indexes accounts positionally:
accounts[0] is “the source”, accounts[1] is “the destination”. The transaction is responsible for
handing them over in the order the program expects. This is why a transaction can declare its accounts:
they are named external things passed in, not private storage discovered mid-execution.
Send + Sync on the trait. This bound is the bridge to the next page. Because a program is immutable code
with no interior state, it is always safe to share one instance across threads. Send + Sync is Rust’s way
of proving that at compile time. When the scheduler runs a batch of non-conflicting transactions on
different cores, it hands each core the same &dyn Program — and the type system guarantees that can’t be a
data race, because there is no mutable state to race over. The statelessness we chose for parallelism
turns out to be exactly what makes sharing safe. That is not a coincidence; it is the same property viewed
twice.
Program 1: Transfer — validate before you mutate
Section titled “Program 1: Transfer — validate before you mutate”The first concrete program moves lamports from one account to another, modeling Solana’s System-program
transfer. The instruction data is the amount, 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)?; // decode the LE u64, or a typed error
// Read the source balance and validate BEFORE mutating anything. let have = accounts[0].lamports; 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(()) }}The ordering is the point. The overdraft check happens before the debit, so a rejected transfer never touches a balance. A test pins this down: transfer 50 lamports out of an account holding 10, and both balances must be exactly as they started.
#[test]fn transfer_rejects_overdraft_without_mutating() { let prog = TransferProgram; let mut accounts = vec![Account::wallet(10), Account::wallet(0)]; let err = prog.process(&mut accounts, &50u64.to_le_bytes()); assert!(matches!(err, Err(SolError::InsufficientFunds { have: 10, need: 50 }))); assert_eq!(accounts[0].lamports, 10); // untouched assert_eq!(accounts[1].lamports, 0); // untouched}Validate-then-mutate matters even more once execution is parallel and speculative. The runtime runs a
program against a clone of the accounts and only commits the result if process returns Ok. So even a
program that mutated and then failed would be rolled back by the runtime — but writing the check first is
cheaper (no wasted writes) and clearer (the invariant is local to the program). We will lean on
clone-then-commit for free rollback when we assemble the runtime.
Program 2: Counter — typed state in the data buffer
Section titled “Program 2: Counter — typed state in the data buffer”The second program shows the other half of the model: a program that keeps typed state in an account’s
data buffer. The state is a struct; the account stores its serialized bytes.
/// The typed state the counter keeps in its account's `data` buffer./// This is the analogue of an Anchor `#[account]` struct.#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]pub struct CounterState { pub count: u64,}
impl CounterState { pub fn load(data: &[u8]) -> Result<Self> { if data.is_empty() { return Ok(CounterState::default()); // never-initialized == count 0 } Ok(serde_json::from_slice(data)?) } pub fn store(&self) -> Result<Vec<u8>> { 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)?; // bytes -> struct state.count = state.count.saturating_add(step); accounts[0].data = state.store()?; // struct -> bytes Ok(()) }}The load → mutate → store cycle is the whole trick. The account holds bytes; the program deserializes
them into a struct it understands, changes the struct, and serializes it back. The runtime never sees the
u64 — only the Vec<u8>. This is precisely how a real on-chain program interprets its accounts, and it is
why the same account model can hold a counter, a token balance, a governance proposal, or anything else: the
meaning is imposed by the owning program, not baked into the account.
Why this is the enabler for parallelism
Section titled “Why this is the enabler for parallelism”Step back and connect the two halves. State lives in named, external accounts. Code lives in stateless programs that only ever touch the accounts they are handed. Put those together and something becomes possible that the contract-holds-its-storage model forecloses: a transaction can list, up front, the exact set of accounts it will read and write.
Contract-holds-storage (Ethereum-style) Stateless-program (Solana-style) ─────────────────────────────────────── ──────────────────────────────── footprint = whatever storage the code footprint = the accounts the happens to touch when it runs transaction NAMES before running │ │ unknown until executed known before execution ▼ ▼ must run one at a time (can't prove can prove two txs are disjoint two txs are disjoint) → run them on different coresThat declarability is worth nothing on its own — a list of account keys is just data. Its value is that the account-locks scheduler can compare two transactions’ declared footprints and, if they share no writable account, run them at the same time on different cores. No footprint declaration, no parallelism. And you cannot declare a footprint if state is hidden inside a contract. The account model is not a stylistic preference — it is the one design decision that unlocks the throughput the whole book is chasing.
Under the hood — the map to real Anchor
Section titled “Under the hood — the map to real Anchor”Everything here has a direct counterpart in a real Anchor program; Anchor just generates the serialization glue we wrote by hand.
solmini (this page) Anchor (real Solana) ─────────────────── ──────────────────── struct CounterState { .. } → #[account] struct Counter { .. } Program::process(..) → #[program] mod handler fn accounts: &mut [Account] → Context<'_> with an #[derive(Accounts)] struct read_u64(data) → deserialized instruction arguments Pubkey::from_seed("..") → a real ed25519 Pubkey / program idWhen you later write #[account] struct Counter { count: u64 }, you are describing the layout of the
data buffer. When you write a handler in a #[program] module, you are writing process. When you write
an #[derive(Accounts)] struct, you are declaring the accounts slice — and, crucially, that declaration
is the footprint the scheduler reads. The Anchor macros hide the (de)serialization, the account-count
checks, and the owner checks we wrote out longhand, but the shape underneath is exactly the two types on
this page.
The architect’s lens
Section titled “The architect’s lens”- Why does it exist? Because to run transactions in parallel you must know each one’s footprint before you run it, and you can only know that if state lives in named, external accounts rather than hidden inside the code that mutates it.
- What problem does it solve? It removes the “footprint unknown until executed” property of contract-holds-its-storage designs, which is the thing that forces serialized, one-at-a-time execution.
- What are the trade-offs? More ceremony: every transaction must enumerate its accounts, programs must validate account count and ownership themselves, and state has to be serialized in and out of raw byte buffers. You trade convenience for declarability.
- When should I avoid it? When you genuinely don’t need parallelism or footprint declaration — a small single-threaded system, or an app where the contract-owns-its-storage model’s ergonomics matter more than throughput. The account model buys parallelism at the cost of ceremony; don’t pay for what you won’t use.
- What breaks if I remove it? Parallel execution. Fold state back inside the code and a transaction can no longer declare its footprint, so the scheduler can’t prove two transactions are disjoint, and you are back to running everything on one core.
Check your understanding
Section titled “Check your understanding”- In one sentence, what is the difference between how Ethereum and Solana place state relative to code, and why does that difference decide whether execution can be parallel?
- An
Accounthas three fields:lamports,data, andowner. Which of them does the runtime itself understand, and which is opaque to it — and who gives the opaque one meaning? - The
Programtrait is boundedSend + Sync. What does that bound let the scheduler do, and why is it always safe to add to a program even though it would be unsafe on arbitrary types? TransferProgramchecks for an overdraft before it debits the source account. The runtime already rolls back failed transactions, so give one reason the in-program check still earns its place.- Map the counter example to Anchor: which part becomes the
#[account]struct, which becomes the#[program]handler, and which becomes the#[derive(Accounts)]struct — and which of those three is the transaction’s declared footprint?
Show answers
- Ethereum puts state inside the contract (code + its own storage), so a call’s footprint is unknown until it runs; Solana puts state in named, external accounts that stateless programs act on, so a transaction can declare its footprint up front — and only a known footprint lets the runtime prove two transactions are disjoint and run them in parallel.
- The runtime understands
lamports(the native balance) andowner(who may write the account).datais an opaqueVec<u8>to the runtime; the program that owns the account gives it meaning by deserializing it into a typed struct. Send + Synclets the scheduler share one&dyn Programacross worker threads running a batch on multiple cores. It is always safe here because a program is immutable, zero-state code — there is nothing to race over — and the marker traits let the compiler prove exactly that, which it could not for a type holding mutable interior state.- Any of: it avoids wasted writes (no debit is performed on a doomed transfer); it keeps the invariant local and readable inside the program rather than relying on the runtime; and it makes the program correct in isolation, so its unit test can assert balances are untouched without needing the whole runtime around it.
CounterState(the struct serialized intodata) becomes the#[account]struct;processbecomes the#[program]handler; theaccounts: &mut [Account]slice becomes the#[derive(Accounts)]struct. That last one — the accounts struct — is the transaction’s declared footprint.