Skip to content

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 front

A 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.

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 owner program can change its data or debit its lamports. 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:

solminiReal AnchorWhat it is
CounterState struct in data#[account] structthe typed state buffer (Anchor adds Borsh + discriminator)
accounts: &mut [Account]Context<T> / ctx.accountsthe accounts this instruction operates on
AccountMeta { key, is_writable } (Day 29)#[derive(Accounts)] + #[account(mut)]the declared account list and its writable flags
Program::processa handler in your #[program] modulethe stateless instruction logic
Program::id()declare_id!(...)the program’s address
account.ownerthe account’s owner fieldthe 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.)

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

  1. 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?
  2. What three fields make up an Account, and what does it mean to say a program is “stateless”?
  3. The counter program keeps its state as bytes in account.data, but the transfer program uses lamports directly. Why two mechanisms — what is each appropriate for?
  4. The transfer program checks the balance before it debits. What property does that ordering give a transaction that fails?
  5. Map four solmini pieces 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
  1. 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.
  2. lamports (a balance), data (an opaque byte buffer the owning program interprets), and owner (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.
  3. lamports is the native balance the runtime understands directly, so value transfer uses it (like the System program). data is for program-specific typed state (the counter’s u64), serialized in and out by the program — appropriate for any state the runtime doesn’t natively know about.
  4. 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.)
  5. 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.