State Transitions: Advancing the World Computer
The previous page, The World State, left us with a single global map — address → account — that every node holds identically. But a map that never changes is a dead ledger. The whole point of a world computer is that the world moves: balances shift, storage slots flip, contracts come into being. This page is about the one operation that moves it, and the surprisingly strict rules that keep every node moving in exact lockstep.
Here is the claim we will build up to, and it is the load-bearing idea of the entire book: a blockchain is a deterministic state machine. It is a current state plus a single, pure rule for turning one state into the next given a transaction. Consensus — all the machinery of blocks, proposers, and attestations you will meet in later parts — does not agree on balances. It agrees only on the order of transactions. Feed that agreed order into the rule, and the resulting state falls out identically for everyone, forever. That is why untrusting strangers can share a computer without trusting each other: they don’t have to trust the result, only replay the rule.
A state machine, precisely
Section titled “A state machine, precisely”Strip away everything else and a blockchain is this:
stateₙ ──apply(tx)──▶ stateₙ₊₁ │ │ state_root state_root' (a 32-byte fingerprint that changes iff the state did)apply is a function. It takes the current world state and one transaction, and returns the next world state. It is deterministic — same state in, same transaction in, always the same state out, on every machine, in every language, at any time. There is no clock, no randomness, no network call hidden inside it. This purity is not an implementation nicety; it is the property the entire system is built to preserve, because it is the only reason replaying the ledger yields the same answer twice.
A block is just a list of transactions with an agreed order. Applying a block is applying its transactions one after another:
stateₙ ──apply(tx₀)──▶ ──apply(tx₁)──▶ ... ──apply(txₖ)──▶ stateₙ₊₁Everything upstream of apply — the peer-to-peer gossip, the mempool, the fork-choice rule, the proposer who bundles transactions into a block — exists for one purpose: to make untrusting strangers agree on which transactions, in what order. Once that order is fixed, no further agreement is needed. The state is not voted on. It is computed.
The three phases of a transition
Section titled “The three phases of a transition”apply does its work in three phases, in a fixed order. The order matters as much as the steps.
┌───────────┐ pass ┌──────────┐ ok ┌──────────┐ │ VALIDATE │──────────▶│ MUTATE │────────▶│ COMMIT │ └─────┬─────┘ └────┬─────┘ └──────────┘ │ fail │ threw ▼ ▼ REJECT REVERT (state untouched, (roll changes back, nonce untouched) but nonce still advances)Phase 1 — validate (checks that reject with no state change)
Section titled “Phase 1 — validate (checks that reject with no state change)”Before a single byte of state is touched, apply asks: is this transaction even valid to include? These are gate checks. If any fails, the function returns an error and the world is left exactly as it was — as if the transaction had never existed.
From the book’s companion crate, ethmini, the validation phase is three questions:
// ---- validation: any failure here rejects without touching state ----let sender = self .accounts .get(&tx.from) .ok_or(EthError::UnknownSender(tx.from))?;
if sender.nonce != tx.nonce { return Err(EthError::NonceMismatch { addr: tx.from, expected: sender.nonce, got: tx.nonce, });}
let value = tx.kind.value();if sender.balance < value { return Err(EthError::InsufficientBalance { addr: tx.from, balance: sender.balance, needed: value, });}Three checks: does the sender exist, is the nonce exactly right, can the sender afford the value. Each returns early with an Err. Notice what has not happened yet: no balance moved, no nonce incremented, no storage written. A rejected transaction leaves no trace. (Real Ethereum has more validation — a valid signature, enough balance to cover gas_limit × gas_price as well as the value, an intrinsic-gas floor — but the shape is identical: cheap checks that gate inclusion, run before anything is mutated.)
Phase 2 — mutate (change the map in place)
Section titled “Phase 2 — mutate (change the map in place)”Once a transaction passes validation it is valid to include, and we begin editing the map. The first edit is always the same, and it happens no matter what the transaction goes on to do:
// The nonce is consumed no matter what happens next — this is what makes// a reverted transaction un-replayable.self.account_mut(tx.from).nonce += 1;Then the transaction’s actual effect runs: a value transfer edits two rows; a contract deployment writes code into a fresh account; a contract call runs bytecode that may write storage slots. This is the phase where the world moves.
Phase 3 — commit or revert
Section titled “Phase 3 — commit or revert”If the effect completed cleanly, the mutations stand: we commit. If a contract call threw mid-execution — ran out of gas, underflowed its stack, hit a bad opcode — we revert: the state changes made during this transaction are rolled back, as if the mutation phase had never run. With one deliberate exception, which the rest of this page is about.
The nonce: enforcing order, one account at a time
Section titled “The nonce: enforcing order, one account at a time”Look again at the second validation check. It is not “the nonce must be higher than last time” — it is exactly equal:
if sender.nonce != tx.nonce { /* reject */ }Every account carries a nonce: a strictly increasing counter of how many transactions it has sent. A transaction names the nonce it claims. It is valid only if that number equals the sender’s current nonce. This single equality gives us two guarantees at once:
account nonce = 4
tx says nonce = 3 → STALE → "already used" → reject tx says nonce = 4 → MATCH → next in line → apply, bump to 5 tx says nonce = 5 → FUTURE → "out of order" → reject (for now)- A stale nonce (lower than current) is a transaction that was already applied. Its number has been consumed and can never match again. This is replay protection: rebroadcast a signed transaction a second time and it now names a used nonce, so it is rejected. You cannot pay Bob twice by resending the same signed message.
- A future nonce (higher than current) is out of order — it depends on a transaction the account hasn’t sent yet. It is rejected as includable right now; a mempool may hold it, waiting for the gap to fill, but the state-transition rule will not apply it until the nonce lines up.
Because the nonce is per account, it imposes a strict sequential order on each sender’s own transactions without forcing any ordering between different senders. Alice’s tx #4 and Bob’s tx #7 are independent; Alice’s tx #4 and Alice’s tx #5 are not. The companion crate makes the replay guarantee a test:
#[test]fn replaying_the_same_tx_fails_the_second_time() { let mut state = WorldState::genesis(&[(alice(), 100)]); let tx = Transaction::transfer(alice(), 0, bob(), 10);
state.apply(&tx).unwrap(); // first time: ok, nonce -> 1 let err = state.apply(&tx).unwrap_err(); // same tx again: nonce now 1, tx says 0 assert!(matches!(err, EthError::NonceMismatch { expected: 1, got: 0, .. })); assert_eq!(state.balance(&bob()), 10); // value moved exactly once}The value moved exactly once, because the second attempt names a nonce that will never be current again.
Rejection vs revert: two very different failures
Section titled “Rejection vs revert: two very different failures”This is the distinction most people blur, and getting it precise is the sharpest tool on this page. A transaction can fail in two completely different ways, with completely different consequences.
┌────────────────────┬──────────────────────┬─────────────────────┐ │ │ REJECTION │ REVERT │ ├────────────────────┼──────────────────────┼─────────────────────┤ │ When │ validation failed │ execution threw │ │ Included in block? │ no — never valid │ YES — it ran │ │ Nonce │ untouched │ ADVANCES │ │ State changes │ none │ rolled back │ │ Sender pays? │ nothing │ yes — gas is spent │ │ Can be retried? │ yes, unchanged │ no — nonce is used │ └────────────────────┴──────────────────────┴─────────────────────┘A rejection means the transaction was never valid to include: wrong nonce, unknown sender, can’t afford the value. The block builder simply leaves it out. State and nonce are untouched; the transaction can be fixed and resubmitted. In ethmini this is any Err(EthError) returned from apply.
A revert means the transaction was included and did run — it was a valid, ordered, paid-for transaction — but its contract call hit an exceptional condition partway through. Its state changes are undone, but its nonce still advances and (on real Ethereum) the sender still pays for the gas the failed execution burned. Why keep the nonce? Because the transaction genuinely happened: it occupied a slot in the block, consumed the sender’s next nonce, and cost real gas. Letting it be replayed would be a bug. In ethmini this is an Ok(Receipt { status: Reverted, .. }).
The one-line summary: rejection = “never happened”; revert = “happened, paid for, accomplished nothing.” A user who overpays for a failed swap has been reverted, not rejected — the chain took the gas and rolled back the trade.
Atomicity: whole, or not at all
Section titled “Atomicity: whole, or not at all”Reverting raises a hard question. A contract call might make a dozen storage writes, move value to three accounts, and then run out of gas on the thirteenth step. What happens to the twelve writes that already landed?
The answer is the property that makes contracts trustworthy at all: atomicity. A transaction’s changes land whole or not at all. There is no in-between state where a token was debited from one account but never credited to another. A failed call leaves zero partial storage writes — the state after a revert is byte-for-byte the state before the call began.
ethmini models this the simplest honest way: snapshot before, restore on failure.
// ---- the tx is valid to include. Snapshot, in case we must revert. ----let snapshot = self.accounts.clone();
self.account_mut(tx.from).nonce += 1; // consumed no matter what
match self.execute(tx) { Ok(receipt) => Ok(receipt), // committed: keep the mutations Err(reverted) => { // Roll the whole world back, then re-apply *only* the nonce bump. let bumped_nonce = self.account(&tx.from).map(|a| a.nonce).unwrap_or(0); self.accounts = snapshot; self.account_mut(tx.from).nonce = bumped_nonce; Ok(Receipt { status: ExecStatus::Reverted, gas_used: reverted.gas_used, return_value: None, created: None, }) }}Read the revert branch carefully, because it encodes the entire rejection-vs-revert distinction in code. On failure it restores the whole account map from the snapshot — undoing every storage write, every value move, everything — and then re-applies exactly one thing: the sender’s nonce bump. The world is reset except that the transaction is now spent. That is a revert in three lines: state back, nonce forward.
The companion test proves both halves at once:
#[test]fn reverted_call_keeps_nonce_but_undoes_storage() { // ... deploy a contract that writes storage[0]=9 then loops forever ... let call = Transaction::call(alice(), 1, addr, 0, 10_000); let receipt = state.apply(&call).unwrap();
assert_eq!(receipt.status, ExecStatus::Reverted); assert_eq!(state.storage(&addr, 0), 0); // storage write undone assert_eq!(state.nonce(&alice()), nonce_before + 1); // nonce still advanced}Under the hood — snapshots vs journals
Section titled “Under the hood — snapshots vs journals”Cloning the whole account map on every transaction, as ethmini does, is honest but wildly inefficient — real Ethereum processes state maps far too large to copy per transaction. Production clients use a journal instead: as execution mutates state, every change is logged as an undo entry (“slot k of account A was v; balance of B was n”). A commit throws the journal away; a revert walks it backward, restoring each recorded value. Same guarantee — the state after a revert equals the state before — achieved by remembering how to undo rather than keeping a full copy.
The journal also nests, which matters because calls nest. When contract A calls contract B, the EVM takes a checkpoint; if B reverts, only B’s changes since that checkpoint are rolled back, and control returns to A, which may catch the failure and continue. This is why Solidity’s try/catch and low-level call returning false work: a sub-call can revert atomically within a still-succeeding outer transaction. Atomicity is not one flat all-or-nothing; it is a tree of nested all-or-nothing scopes, each with its own checkpoint.
Why this is the whole basis of agreement
Section titled “Why this is the whole basis of agreement”Now the throughline closes. Because apply is deterministic — no clocks, no randomness, validation before mutation, atomic commit-or-revert — every honest node that replays the same ordered transactions against the same starting state reaches the identical next state. Not “a compatible state” or “close enough.” Bit-for-bit identical, provably so, because they compare the resulting state root:
let tx = Transaction::transfer(alice(), 0, bob(), 30);let root_before = state.state_root();state.apply(&tx).unwrap();assert_ne!(state.state_root(), root_before); // the state movedThe state root (built in the next part, State & Tries) is a single hash over the entire world state. Two nodes that ran the same ordered transactions produce the same root; a node that computed anything differently produces a visibly different one and is caught. So the astonishing bargain of a public blockchain is this: strangers never have to agree on the state at all. They agree only on the order of transactions — the hard, expensive job that consensus does — and then each computes the state alone, checking their neighbours with a single 32-byte comparison. Agreement on the world is a derived fact, not a negotiated one. That is what a deterministic state machine buys you, and it is the entire reason a world computer can exist without a trusted operator.
The architect’s lens
Section titled “The architect’s lens”- Why does it exist? A shared, mutable world needs a rule for how it changes that every participant can apply independently and get the same answer.
applyis that rule; it is the only thing that turns an agreed order of transactions into an agreed state. - What problem does it solve? It lets untrusting strangers converge on one world state without trusting each other’s results — each node recomputes locally and verifies with a single hash, so honesty is checkable, not assumed.
- What are the trade-offs? Determinism forbids everything convenient — real clocks, true randomness, external I/O — inside the rule, because any non-determinism would make replay diverge. Every node must re-execute every transaction, which caps throughput: the chain runs at the speed of one machine, not many.
- When should I avoid it? When you don’t need shared, verifiable, replayable state. If one trusted party can just hold the database, a plain server is faster and cheaper; the state machine’s cost only pays off when no one is trusted to hold the answer.
- What breaks if I remove it? Determinism is load-bearing. Allow one non-deterministic or non-atomic
applyand replay stops converging: nodes compute different state roots from the same transactions, consensus over order no longer implies consensus over state, and the shared world computer forks into disagreeing copies.
Check your understanding
Section titled “Check your understanding”- A blockchain is a deterministic state machine. What, precisely, does consensus agree on — and what does it not agree on?
- Walk the three phases of
applyin order. Why must validation run entirely before any mutation? - An account’s nonce is 4. Classify a transaction claiming nonce 3, one claiming 4, and one claiming 6 — and say what happens to each.
- A user’s contract call is included in a block but runs out of gas halfway through 12 storage writes. Was it rejected or reverted? What happens to the writes, the nonce, and the gas?
- Explain how atomicity and determinism together let a node in Tokyo and a node in Berlin agree on the world state without ever exchanging a single balance.
Show answers
- Consensus agrees only on the order of transactions (which transactions, in what sequence). It does not agree on the resulting state — that is computed by each node by replaying the ordered transactions through the deterministic
applyrule. Balances are never voted on or transmitted; they fall out of the computation identically for everyone. - Validate (reject if the sender is unknown, the nonce is wrong, or the value is unaffordable), then mutate (bump the nonce, then run the transaction’s effect), then commit-or-revert. Validation must run first so that an invalid transaction leaves no trace — a rejected transaction touches neither state nor nonce, which is only possible if the gate checks happen before any mutation.
- Nonce 3 is stale (already used) → rejected as already-used; state and nonce untouched. Nonce 4 matches the current nonce → applied, and the nonce bumps to 5. Nonce 6 is a future nonce (a gap) → rejected as out-of-order for now; a mempool may hold it until nonce 5 arrives, but the state-transition rule won’t apply it while the nonce doesn’t line up.
- Reverted — it was valid enough to be included and it ran, so it is not a rejection. All 12 storage writes (and any value moved) are rolled back to the pre-call state; the sender’s nonce still advances (so it can’t be replayed); and the gas is still spent — the sender paid for a transaction that accomplished nothing.
- Because
applyis deterministic (same state + same ordered transactions → same next state, with no clocks, randomness, or I/O) and atomic (each transaction’s changes land whole or not at all, so no node is ever left in a half-written state), both nodes replaying the identical ordered transaction list arrive at bit-for-bit identical state. They confirm it by comparing a single 32-byte state root — never a balance. Agreement on the world is derived from agreement on order plus a shared rule, not negotiated.