Skip to content

Contrast: Contract-Owned Storage vs External Accounts

The last three pages built Solana’s state model from the ground up: everything is an account, programs are stateless and act on the accounts they’re handed, and PDAs let a program own accounts at deterministic, keyless addresses. Every one of those choices is a departure from the model most developers meet first — Ethereum’s. This page puts the two side by side so the departure is precise rather than vibes.

There is really only one difference that matters, and everything else follows from it. Ethereum keeps a contract’s state inside the contract. Solana keeps a program’s state outside it, in named accounts a transaction must declare before it runs. That is the whole page. The rest is why that difference is the hinge the book’s throughline — how do you build a single global state machine that runs at hardware speed without falling apart? — turns on. Hidden state forces serial execution; declared state permits parallel execution. We’re about to see exactly why.

Picture the simplest stateful contract there is: a counter. In Ethereum, the counter’s value lives in the contract’s own storage:

// Ethereum: state lives INSIDE the contract
contract Counter {
uint64 public count; // a storage slot, private to this contract
function increment() public {
count += 1; // reads & writes the contract's own storage slot
}
}

The count variable is a storage slot. It is owned by, addressed within, and reachable only through the Counter contract. On disk, the contract account carries a storageRoot — the root of a Merkle trie holding every slot the contract has ever written. Code and state are welded into one account.

In Solana, the same counter splits into two separate things: a program (pure code, stateless) and an account it owns (pure data):

// Solana (via the book's solmini crate): state lives OUTSIDE the program
#[derive(Serialize, Deserialize, Default)]
pub struct CounterState { pub count: u64 } // ← lives in an EXTERNAL account's `data`
impl Program for CounterProgram {
fn process(&self, accounts: &mut [Account], data: &[u8]) -> Result<()> {
let mut state = CounterState::load(&accounts[0].data)?; // read the account it was HANDED
state.count = state.count.saturating_add(1);
accounts[0].data = state.store()?; // write it back
Ok(())
}
}

Notice what the Solana version doesn’t have: any storage of its own. The program is a unit struct — zero bytes of state. The count lives in accounts[0], an account passed into the function. The program never reaches out and finds its state; the state is delivered to it. That inversion is the entire game.

ETHEREUM SOLANA
──────── ──────
┌─────────────────────────┐ ┌──────────────────────┐
│ Contract account │ │ Program account │
│ addr: 0xCounter… │ │ id: Counter1111… │
│ ┌───────────────────┐ │ │ code (executable) │
│ │ code (EVM bytes) │ │ │ STATELESS │
│ ├───────────────────┤ │ └──────────┬───────────┘
│ │ storageRoot ──────┼──┼─► trie owns │
│ │ slot0: count=7 │ │ ▼
│ │ slot1: … │ │ ┌──────────────────────┐
│ └───────────────────┘ │ │ Data account │
│ code + state TOGETHER │ │ addr: (a PDA) │
└─────────────────────────┘ │ data: {count: 7} │
│ owner: Counter1111… │
state HIDDEN inside contract └──────────────────────┘
state in a NAMED, EXTERNAL account

On the left, count is buried inside the contract’s trie. Nothing outside the contract knows the slot exists or what it holds without asking the contract to run. On the right, count lives in its own account with its own address — a first-class, independently addressable object the runtime can see, list, and reason about without executing a line of the program.

Here is the consequence, and it is not subtle. Suppose the runtime is handed two transactions and wants to know: can I run these two at the same time on different cores? The answer is yes only if they touch disjoint state — if they share no slot that one writes and the other reads or writes, they can’t collide.

On Ethereum, the runtime cannot answer that question in advance. A call to Counter.increment() might touch slot 0. But it might also call another contract, which touches another contract’s storage, which calls a third — and which slots get touched can depend on the input data and on current on-chain state. The storage footprint of a call is hidden inside the call and is only known once the call has run.

Ethereum: "which state will this touch?"
───────────────────────────────────────
tx → run contract → ...which calls contract B → ...which reads slot X
└─ footprint only KNOWN once you've already run it
If you must run it to learn what it touches,
you cannot safely run two at once → SERIAL.

You cannot parallelize what you cannot predict. To stay safe, the EVM executes transactions one at a time, in order. Correctness is guaranteed by never running two things concurrently — which also caps throughput at whatever one thread can do.

Why declared state permits parallel execution

Section titled “Why declared state permits parallel execution”

Solana removes the guessing by moving the state out and making every transaction declare its footprint up front. A Solana transaction carries a list of the accounts it will touch, each flagged read-only or writable, before the runtime executes anything:

Solana transaction (the account list, declared BEFORE running)
──────────────────────────────────────────────────────────────
accounts: [ {key: alice, writable} ← will be mutated
{key: bob, writable}
{key: Counter, read-only} ← program, not mutated
{key: PDA, writable} ]

Now the runtime can answer the parallelism question with pure set arithmetic, no execution required: two transactions can run concurrently iff their writable-account sets are disjoint (and no writable of one appears in the other’s read set). Alice-pays-Bob and Carol-pays-Dave touch four different accounts, so they run on two cores at once. Alice-pays-Bob and Alice-pays-Eve both write Alice, so they’re serialized — but only those two, not the whole world.

Solana: footprint is DECLARED, so the scheduler sorts before it runs
────────────────────────────────────────────────────────────────────
tx1 writes {alice, bob} ┐
tx2 writes {carol, dave} ├─ disjoint → run in PARALLEL
tx3 writes {alice, eve} ┘ shares alice with tx1 → run AFTER tx1
No execution needed to decide this — just compare the declared sets.

That declared list is the exact information the parallel scheduler needs. We externalized state for one reason above all: to make the footprint knowable in advance. This is the setup for the next part of the book — parallel execution is nothing more than this set-comparison, applied transaction by transaction, across many cores. Everything the account model built (the overview through PDAs) existed to hand the scheduler this one fact.

If you think in Solidity, the translation is mechanical. Ethereum’s storage patterns map onto Solana’s account patterns one for one:

Ethereum patternSolana equivalentWhat it is
A contract’s storage slot (uint64 count)The owning program’s data account (data: {count})A single blob of the program’s state
mapping(address => Balance)One account per user (often a PDA)A per-key record, but each key gets its own addressable account
mapping(address => mapping(...)) (nested)A PDA seeded by both keysThe composite key becomes the PDA’s seeds
contract addressprogram id (declare_id!)The code’s address
msg.sender implicitly authorizedA Signer account you must check yourselfAuthority is an account in the list, validated explicitly

The pivotal row is the mapping. In Ethereum, mapping(address => uint) is a single conceptual object living in one contract’s storage; the runtime sees one contract touched, never the individual entries. In Solana, that mapping shatters into N independent accounts — one per address, each a PDA derived from the user’s key (PDAs). That shattering is what creates the parallelism: two users updating their own balances touch two different accounts, so they never serialize against each other. Ethereum’s convenient single mapping is precisely the thing that would force those two updates into the same storage trie and back onto one thread.

Under the hood — the price Solana pays for a knowable footprint

Section titled “Under the hood — the price Solana pays for a knowable footprint”

Nothing is free. Externalizing state buys the knowable footprint, but the bill comes in two line items, both of which land on you, the program author:

  • Mandatory up-front declaration. Every account an instruction touches must be in the transaction’s account list. Forget one and the instruction fails — the program literally cannot reach an account it wasn’t handed. Ethereum never makes you do this; there, you just call and the storage is there. On Solana, the client must compute the full account list (including deriving every PDA) before it can even build the transaction.
  • Self-managed account validation. Because a program receives its accounts as an untyped list of buffers, it must verify each one itself: is this the right key? the right owner? a signer? Ethereum’s msg.sender and typed storage give you some of this for free. Solana hands you raw accounts and trusts nothing — validating them is your job. This is the sharp edge the stateless programs page warned about, and the reason Anchor’s #[account(...)] constraints exist: they turn easy-to-forget checks into declarative attributes the framework enforces.

So the trade is explicit: Solana accepts more work at the edges — declaration on the client, validation in the program — to buy the one thing Ethereum’s model cannot give it: a footprint the scheduler can read before it runs anything.

This page is really about one architectural decision — externalizing state — so view it through the five questions.

  • Why does it exist? Because a runtime can only parallelize work whose data footprint it knows in advance, and hidden-inside-the-contract state is unknowable until executed. Externalizing state is the precondition for a scheduler that reads footprints instead of guessing them.
  • What problem does it solve? Serial execution. Ethereum’s model caps throughput at one thread because it must run a call to learn what it touches; external, declared accounts let Solana sort transactions into parallel batches before running any of them.
  • What are the trade-offs? You pay with mandatory up-front account declaration (the client must compute the full footprint, PDAs included) and self-managed validation (the program must check every account’s key, owner, and signer status itself).
  • When should I avoid it? When you want opaque, self-contained state and don’t need parallelism — a low-throughput chain where the developer ergonomics of “storage is just there” outweigh raw speed. Ethereum’s model is genuinely simpler to write against for that case.
  • What breaks if I remove it? The parallel scheduler. Fold state back inside programs and the footprint becomes unknowable again; the runtime must serialize to stay correct, and Solana’s entire throughput bet collapses back to one-transaction-at-a-time.
  1. State the single difference between the Ethereum and Solana state models in one sentence, and name the one consequence that follows from it.
  2. Why can the Ethereum runtime not know a transaction’s storage footprint before executing it, and why does that force serial execution?
  3. A Solana transaction declares its accounts up front, each flagged read-only or writable. What exact computation does that let the scheduler perform, and when may two transactions run in parallel?
  4. Map an Ethereum mapping(address => Balance) onto its Solana equivalent. Why does that mapping being “shattered” into many accounts create parallelism that the single Ethereum mapping cannot?
  5. Externalizing state is a deliberate trade. Name the two prices Solana pays for a knowable footprint, and give the real incident that shows what happens when the second one is skipped.
Show answers
  1. Ethereum keeps a contract’s state inside the contract (welded to its code); Solana keeps a program’s state outside it, in named accounts a transaction must declare before running. The consequence: Ethereum must execute serially, Solana can execute in parallel.
  2. Because a call’s storage footprint is private to the contract and can depend on input data and current on-chain state — a call may reach into other contracts’ storage — so the only way to learn what it touches is to run it. Since you must run it to know its footprint, you cannot safely run two at once, which forces one-at-a-time serial execution.
  3. It lets the scheduler compare the transactions’ declared account sets with plain set arithmetic, no execution required. Two transactions may run in parallel iff their writable-account sets are disjoint and neither writes an account the other reads — i.e., they share no conflicting account.
  4. mapping(address => Balance) becomes one account per address, typically a PDA derived from the user’s key. In Ethereum the mapping is a single object inside one contract’s storage, so any two updates touch the same contract and serialize; in Solana each entry is its own addressable account, so two users updating their own balances touch two different accounts and run on different cores.
  5. The two prices are (a) mandatory up-front account declaration — the client must compute the full footprint, deriving every PDA, before building the transaction — and (b) self-managed account validation — the program must check each handed-in account’s key, owner, and signer status itself. The Wormhole hack of 2 February 2022 (~$320M) shows the cost of skipping (b): a spoofed, unvalidated “instructions sysvar” account bypassed the signature check and minted unbacked wrapped ETH.