Day 28 · The Account Model
On Day 27 you built the clock. Today you build the thing the clock orders: transactions over accounts. And here Solana makes a design choice that is the quiet key to its whole performance story — one you’ve used in every Anchor program you’ve written, maybe without naming it. The choice is to separate code from state. Get this, and Day 29’s parallelism stops looking like magic and starts looking inevitable.
The split that makes everything else possible
Section titled “The split that makes everything else possible”Recall how ethmini modeled a smart contract: a contract account holds both
its code and its storage. State lives inside the contract. That’s intuitive — but it has a hidden
cost we flagged in the overview: you can’t know what storage a contract call will touch until you run it,
because the storage is the contract’s private business. So the runtime must execute calls one at a time.
Solana splits the two apart:
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 that the program owns and is the only thing allowed to write. Because state is now in named, external accounts, a transaction can list the accounts it will touch before it runs — which is exactly the information Day 29’s scheduler needs to parallelize. The account model isn’t just a different data layout; it’s the precondition for parallel execution.
Everything is an account
Section titled “Everything is an account”In Solana, an account is dead simple: a balance, a raw data buffer, and an owner.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Account { pub lamports: u64, // native balance (Solana's smallest unit) pub data: Vec<u8>, // an opaque buffer a program interprets however it likes pub owner: Pubkey, // the program id allowed to mutate this account's data }A Pubkey is the 32-byte address. Real keys are ed25519 public keys; we derive ours by hashing a label,
which keeps tests readable and is all the model needs:
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] pub struct Pubkey([u8; 32]);
impl Pubkey { pub fn from_seed(seed: &str) -> Self { Pubkey(sha256(seed.as_bytes())) } }That’s the entire data model. A wallet is an account with some lamports and empty data. A program’s
state lives in an account whose data holds a serialized struct and whose owner is that program. “Code”
itself is an account too (in real Solana, marked executable) — we keep programs in a registry instead,
since we’re not modeling bytecode loading.
Stateless programs: pure functions over accounts
Section titled “Stateless programs: pure functions over accounts”A program is one method. It takes the accounts the transaction handed it (in a fixed, agreed order) plus
an opaque data blob — the instruction — and mutates the accounts. That signature is the Solana
programming model:
pub trait Program: Send + Sync { fn process(&self, accounts: &mut [Account], data: &[u8]) -> Result<()>; }The Send + Sync bound is not decoration — it’s the compiler-checked promise that this code is safe to
share across threads, which Day 29 cashes in. A program holds no state, so sharing it is always safe;
the marker traits let the type system prove that. Our programs are zero-sized unit structs to make the
“stateless” claim literal.
The transfer program moves lamports between two accounts — Solana’s System-program transfer in
miniature. The instruction data is the amount as a little-endian u64:
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)?; 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(()) } }The counter program is the one that shows off typed data. It keeps a u64 in its account’s data
buffer by serializing a struct in and out — and that struct is the direct analogue of an Anchor
#[account]:
#[derive(Serialize, Deserialize, Default)] pub struct CounterState { pub count: u64 } // ← the Anchor #[account] of this program
impl Program for CounterProgram { fn process(&self, accounts: &mut [Account], data: &[u8]) -> Result<()> { if accounts.len() != 1 { /* AccountCount error */ } 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 exercises both: the transfer rejects an overdraft without mutating (it checks
before it debits, so a failed transfer leaves balances intact), and the counter round-trips its typed
state through three increments to count = 3. Notice the data buffer is just bytes — the program
decides what type lives there. That is “typed account data”: the bytes are opaque to the runtime and
meaningful only to the owning program.
Under the hood — what real Anchor adds: discriminators, Borsh, and rent
Section titled “Under the hood — what real Anchor adds: discriminators, Borsh, and rent”Our CounterState uses serde_json for clarity. Real Anchor uses Borsh (a compact binary format) and
prepends an 8-byte discriminator to every account’s data — a hash of the account’s type name — so a
program can tell “is this buffer actually a CounterState, or did someone hand me the wrong account?” That
tag is a safety check our model skips. Two more real-world details worth knowing:
- Owner enforcement. On real Solana the runtime guarantees only an account’s
ownerprogram can change itsdataor debit itslamports. Our model trusts programs to touch only declared accounts; the real runtime enforces it. - Rent. Accounts occupy validator memory, so they must hold a minimum balance to be rent-exempt; below it, an account can be purged. Account data also has a hard size cap (on the order of ~10 MiB). These are the economics of “state lives in accounts” — storage isn’t free, and somebody funds it.
The bridge: this is your Anchor mental model
Section titled “The bridge: this is your Anchor mental model”You’ve written Anchor programs, so map the pieces directly — solmini is the same skeleton with the glue
removed:
solmini | Real Anchor | What it is |
|---|---|---|
CounterState struct in data | #[account] struct | the typed state buffer (Anchor adds Borsh + discriminator) |
accounts: &mut [Account] | Context<T> / ctx.accounts | the accounts this instruction operates on |
AccountMeta { key, is_writable } (Day 29) | #[derive(Accounts)] + #[account(mut)] | the declared account list and its writable flags |
Program::process | a handler in your #[program] module | the stateless instruction logic |
Program::id() | declare_id!(...) | the program’s address |
account.owner | the account’s owner field | the program permitted to write it |
The #[derive(Accounts)] struct you write at the top of every instruction — the one listing each account
and tagging it mut or Signer — is precisely the up-front footprint declaration this project is built
around. You’ve been feeding Solana’s scheduler its parallelism information all along; Day 29 shows what it
does with it. (Program-derived addresses — PDAs — are just accounts a program owns at a deterministic
address; they slot into this same model as “accounts owned by the program,” nothing more exotic.)
The thread
Section titled “The thread”What does building the account model force you to understand? That state and code are separable, and
that separating them is what makes a transaction’s footprint knowable — the single fact Day 29’s
parallel scheduler depends on. And what is Rust protecting you from? Two things, quietly. The typed
load/store round-trip means a malformed data buffer becomes a typed Err, not a silent
misinterpretation of bytes — the same reason real Anchor tags accounts with a discriminator. And the
Program: Send + Sync bound is the compiler proving, today, that tomorrow’s worker threads can share these
programs without a data race. The account model is where Solana’s throughput bet is set up; next we watch
it pay off.
→ Next: Day 29 · Parallel Execution · Prev: Day 27 · Proof of History · Back to the Project 7 overview
Check your understanding
Section titled “Check your understanding”- Ethereum keeps a contract’s storage inside the contract; Solana keeps a program’s state in external accounts. Why does that one difference determine whether transactions can be parallelized?
- What three fields make up an
Account, and what does it mean to say a program is “stateless”? - The counter program keeps its state as bytes in
account.data, but the transfer program useslamportsdirectly. Why two mechanisms — what is each appropriate for? - The transfer program checks the balance before it debits. What property does that ordering give a transaction that fails?
- Map four
solminipieces onto their real Anchor equivalents, and explain why a Solana program must validate the accounts it’s handed (cite the failure mode if it doesn’t).
Show answers
- If state is hidden inside a contract, the runtime can’t know which state a call will touch until it runs it, so it must run calls serially (any two might collide). If state is in named external accounts, a transaction can declare its accounts up front, so the runtime can see — before executing — which transactions are independent and run those in parallel.
lamports(a balance),data(an opaque byte buffer the owning program interprets), andowner(the program id allowed to mutate it). “Stateless” means the program code holds no state of its own — all mutable state lives in the accounts passed to it, so the program is just a pure function over those accounts.lamportsis the native balance the runtime understands directly, so value transfer uses it (like the System program).datais for program-specific typed state (the counter’su64), serialized in and out by the program — appropriate for any state the runtime doesn’t natively know about.- It gives atomic rollback: because the check happens before any mutation, a transfer that fails on insufficient funds returns an error having changed nothing. (In the runtime, a failed transaction’s working copy is simply discarded, so even partial mutations never reach the real state.)
- e.g.
CounterState→#[account]struct,accounts: &mut [Account]→Context/ctx.accounts,AccountMeta{is_writable}→#[account(mut)]in#[derive(Accounts)],Program::id()→declare_id!. A program receives accounts as a list of buffers and must check each one’s key/owner/signer itself; forgetting a check lets an attacker substitute a malicious account (as in the 2022 Wormhole hack, where a spoofed sysvar account bypassed signature verification). Anchor’s#[account(...)]constraints make those checks declarative so they’re hard to forget.