Skip to content

World State and the State-Transition Function

The previous page gave us the two nouns of the account model: an Address and the mutable Account record it points at — nonce, balance, storage, code. A single account is inert, though. On its own it can’t do anything; it just sits there holding a balance. This page assembles those accounts into a world state and then writes the one function that makes a blockchain a blockchain: the rule that advances that world from one valid state to the next.

That rule is the heart of the whole throughline. Untrusting strangers agree on the state of a shared world computer because the rule for advancing it is a pure, deterministic function — feed the same starting world the same transaction and every honest machine on Earth computes the identical next world. Everything else in this book (blocks, gas, consensus) exists only to decide which transactions to feed this function, and in what order. Here we build the function itself.

Strip away the mystique and a blockchain is one of the oldest ideas in computer science: a state machine. You need exactly two things.

  1. A state — a snapshot of the whole world at one instant.
  2. A transition rule — a function that, given the current state and an input, produces the next state.
state₀ ──apply(tx₁)──▶ state₁ ──apply(tx₂)──▶ state₂ ──apply(tx₃)──▶ ...
│ │ │
state_root state_root' state_root'' (one hash per state)

Our state is the WorldState; our input is a Transaction; our rule is a method called apply. Because apply is deterministic — same world plus same transaction always yields the same next world — the state is never a matter of opinion. It is a pure function of the ordered list of transactions. That single fact splits the blockchain problem cleanly in two:

  • Ordering is hard and political. Deciding which transactions come next, and in what sequence, is the job of consensus — proof-of-work in Bitcoin, proof-of-stake in Ethereum. It is where the genuine difficulty and the money live.
  • State is easy and mechanical. Once the order is fixed, replaying it is a deterministic function. Nothing to argue about; you just run apply over and over.

This page owns the second half. We take the order as given and build the machine that turns an ordered transaction stream into a world.

The world state is every account, keyed by its address:

/// The entire ledger: every account, keyed by address.
#[derive(Clone, Debug, Default)]
pub struct WorldState {
pub accounts: HashMap<Address, Account>,
}

A HashMap<Address, Account> gives us O(1) lookup — given an address, fetch its account — which is exactly the access pattern a chain needs. This is where Ethereum’s model diverges sharply from Bitcoin’s. Bitcoin has no accounts at all; it tracks a set of unspent coins (UTXOs), and a “balance” is just whatever coins your keys can unlock. Ethereum keeps a mutable record you address by name and edit in place. That difference is what lets a contract be a thing that persists and remembers across transactions — the foundation of a “world computer.”

The HashMap choice has a subtle cost that surfaces the moment we try to commit the state to a single hash. We defer that reckoning to the next page; for now, just note that fast keyed access and deterministic commitment pull in opposite directions.

Every state machine needs a state zero. A blockchain calls it genesis: the initial allocation of balances, baked into the very first block, that everyone agrees to start from. There is no transaction that creates these balances — they are the axiom the chain is built on.

/// Build a starting world by funding a list of addresses — the "genesis"
/// allocation. Real chains ship a genesis block that does exactly this.
pub fn genesis(allocations: &[(Address, u128)]) -> Self {
let mut state = WorldState::new();
for (addr, balance) in allocations {
state.accounts.insert(*addr, Account::with_balance(*balance));
}
state
}

Real Ethereum’s genesis funded the addresses from the 2014 crowdsale and a handful of others; ours funds whatever list of (address, balance) pairs we hand it. Same idea: assert some starting balances by fiat, then let the transition rule take over from there. Everything after genesis must be earned by a valid transaction.

Before we can write the rule, we need to say what an input is. A transaction always answers three questions — who is sending it, which nonce they claim, and what they want done — but “what they want done” comes in exactly three shapes:

/// What a transaction does. Three shapes, mirroring Ethereum's.
#[derive(Clone, Debug)]
pub enum TxKind {
/// Move value from sender to a recipient (a plain send).
Transfer { to: Address, value: u128 },
/// Create a new contract account holding `code`, optionally endowed.
Deploy { code: Vec<u8>, value: u128 },
/// Invoke an existing account: move value, and if it has code, run it.
Call { to: Address, value: u128, gas_limit: u64 },
}
  • Transfer — move native value from the sender to some recipient. The simplest thing a chain can do; the whole of it is “subtract here, add there.”
  • Deploy — publish bytecode as a new contract account, at a fresh address derived from the sender and nonce. We build this out on the deploying-and-calling page.
  • Call — invoke an existing account. If the target is a plain wallet, a call is just a value transfer; if the target holds code, we run that code with a gas budget. This is the shape that makes Ethereum a computer rather than a ledger, and it waits on the VM and gas pages.

The Transaction struct wraps a kind with the sender identity and the nonce:

/// A transaction: who sent it, the nonce it claims, and what it does.
#[derive(Clone, Debug)]
pub struct Transaction {
pub from: Address,
pub nonce: u64,
pub kind: TxKind,
}

We do not model signatures here. In a real chain, from is not a field the sender fills in — it is recovered from an ECDSA signature over the transaction, so you cannot spend from an address whose private key you don’t hold. Our from stands in for “the address a valid signature recovered to.” That is the one honest simplification on this page; the cryptography part is where signatures earn their keep.

Now the rule itself. Its structure is dictated by one discipline that runs through all of blockchain engineering: validate before you mutate. Never touch a byte of state until you have proven the transaction is allowed to change it. apply reads in three phases.

pub fn apply(&mut self, tx: &Transaction) -> Result<Receipt> {
// ---- Phase 1: VALIDATE. Any failure here REJECTS, touching no 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,
});
}
// ---- Phase 2: SNAPSHOT, then consume the nonce no matter what. ----
let snapshot = self.accounts.clone();
self.account_mut(tx.from).nonce += 1;
// ---- Phase 3: EXECUTE. On a contract throw, roll back but keep the nonce. ----
match self.execute(tx) {
Ok(receipt) => Ok(receipt),
Err(reverted) => {
let bumped = self.nonce(&tx.from);
self.accounts = snapshot; // undo everything...
self.account_mut(tx.from).nonce = bumped; // ...except the nonce
Ok(Receipt { status: ExecStatus::Reverted, gas_used: reverted.gas_used,
return_value: None, created: None })
}
}
}

Phase 1 is the gate, and it reads only — it never writes. Three ways a transaction can be turned away, each a distinct variant of EthError:

  • Unknown sender. The from address has no account at all. It has no balance to spend and no nonce to check, so there is nothing to send from. (In real Ethereum an account springs into existence the first time it is funded; you cannot send from nothing.)
  • Nonce mismatch. The transaction’s nonce does not equal the sender’s current nonce. A stale nonce means “already spent”; a future nonce means “out of order.” Either way, reject.
  • Insufficient balance. The sender cannot cover the value being moved. Checked here, up front, so an unaffordable transaction is rejected — never half-applied and then unwound.

The crucial property: when Phase 1 returns Err, the world state is bit-for-bit unchanged. The caller who tried to submit a bad transaction sees exactly the world they started with. Rust’s ? operator makes this the natural path — the early return happens before any mutation, and because we only ever read self.accounts in this phase, there is nothing to undo.

Contrast that with the Reverted path in Phase 3. A transaction can pass validation, get included, and then have its contract call blow up (run out of gas, underflow the stack). That is a different animal entirely: the transaction was valid to include, so its sender’s nonce is consumed and it counts as mined — but its state changes are rolled back. We build that path out on the gas page; the one-line summary is:

Err(EthError) → REJECTED. Never included. State untouched.
Ok(status=Reverted) → INCLUDED. Nonce consumed. State changes undone.
Ok(status=Success) → INCLUDED. Applied in full.

Once validation passes, moving value is deliberately mundane — increment one balance, decrement another, in place:

/// Move `value` from `from` to `to`. Assumes the balance check already
/// passed (callers do it up front so an unaffordable tx is rejected).
fn transfer_value(&mut self, from: Address, to: Address, value: u128) {
self.account_mut(from).balance -= value;
self.account_mut(to).balance += value;
}

This is the account model in three lines. There are no coins to select, no change to compute, no UTXOs to consume and recreate the way Bitcoin does — just two integers, one down and one up. The reason transfer_value can assume the balance is sufficient is precisely the validation gate: because Phase 1 already proved sender.balance >= value, that -= can never underflow. The discipline of validating first is what lets the mutation stay this simple.

The nonce is the quietest field on the account, and the most important for safety. Here is the attack it stops. Suppose Alice signs a transaction paying Bob 10 units and broadcasts it. It gets included; Bob is paid. What stops Bob — or anyone who saw the transaction on the network — from re-broadcasting the exact same signed bytes ten more times and draining Alice’s account?

The nonce. A transaction must name the sender’s current nonce, and applying it consumes that nonce by bumping it. So the second copy of the very same transaction now names a stale nonce and hits the validation gate:

Alice's account nonce: 0
┌─────────────────────────────────────────────────────────────┐
│ apply(tx{from: alice, nonce: 0, transfer 10 → bob}) │
│ nonce 0 == account 0 ✔ → included, account nonce → 1 │
└─────────────────────────────────────────────────────────────┘
Alice's account nonce: 1
┌─────────────────────────────────────────────────────────────┐
│ apply(SAME tx{nonce: 0}) again (a replay) │
│ nonce 0 != account 1 ✘ → EthError::NonceMismatch │
│ REJECTED — Bob is paid exactly once │
└─────────────────────────────────────────────────────────────┘

The companion crate proves this in 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: account now at 1, tx says 0
assert!(matches!(err, EthError::NonceMismatch { expected: 1, got: 0, .. }));
assert_eq!(state.balance(&bob()), 10); // value moved exactly once
}

The nonce also fixes a transaction’s place in line: because each one must name the next number in sequence, an account’s transactions can only be applied in the order the sender signed them. Replay protection and ordering fall out of the same single counter.

  • Why does it exist? Because a blockchain has to let mutually-distrusting strangers agree on a shared world without a referee. Modelling the world as a deterministic state machine — a state plus a pure apply rule — makes the resulting state a fact anyone can recompute rather than an authority’s decree.
  • What problem does it solve? Agreement without trust. Once the order of transactions is fixed, apply reduces “what is the state?” from a negotiation to a computation: every honest node replays the same inputs and lands on the same world, down to the byte.
  • What are the trade-offs? Determinism is a straitjacket. apply may not consult the wall clock, random numbers, the network, or anything else that differs between machines, or nodes would diverge. And validating before every mutation costs a full read pass over the touched state before any write.
  • When should I avoid it? When you do not actually need trustless agreement. If one trusted party owns the ledger, an ordinary database with transactions is faster, cheaper, and far simpler — you are paying for a property you aren’t using.
  • What breaks if I remove it? Everything downstream. With no deterministic transition rule, two nodes fed the same transactions could reach different worlds, the state root would stop being a meaningful fingerprint, and consensus would have nothing to agree about. apply is the thing all the machinery is built to protect.
  1. The page claims “a blockchain is a deterministic state machine” and that “consensus is only about agreeing on the order.” Once the order of transactions is fixed, why is the resulting world state not a matter of opinion?
  2. Name the three rejection conditions in apply’s validation gate, and state the one property they all share about the world state when they fire.
  3. Distinguish a rejected transaction (Err(EthError)) from a reverted one (Ok(status: Reverted)). What happens to the sender’s nonce in each case, and why is that difference deliberate?
  4. Walk through why re-broadcasting an already-included transaction is rejected the second time. Which field does the work, and what specifically changed between the first and second apply?
  5. transfer_value subtracts from the sender’s balance with a plain -= and no underflow check. Why is that safe? What earlier step guarantees it can never go negative?
Show answers
  1. Because apply is a pure, deterministic function: the same starting state plus the same transaction always produces the same next state. So once every node agrees on the ordered list of transactions, each one replays them and computes bit-for-bit the identical world — there is nothing left to disagree about. All the genuine difficulty lives in agreeing on the order, which is consensus’s job, not apply’s.
  2. Unknown sender (the from address has no account), nonce mismatch (the transaction’s nonce ≠ the account’s current nonce), and insufficient balance (the sender can’t cover the value). Shared property: when any of them fires, apply returns Err and the world state is left completely untouched — the transaction is simply not part of history.
  3. A rejected transaction was never valid to include, so it is discarded and the sender’s nonce is not consumed — the world does not move at all. A reverted transaction was valid and is included: its sender’s nonce is consumed (so it can’t be replayed), even though its other state changes are rolled back. The difference is deliberate because a reverted transaction still occupied a slot and must not be re-runnable, whereas a rejected one never happened.
  4. The nonce does the work. On the first apply, the transaction’s nonce matches the account’s nonce, so it is included and the account’s nonce is bumped by one. The same signed transaction still names the old nonce, but the account has now moved on — so the second apply sees a nonce mismatch (account expects 1, transaction says 0) and rejects it. Bob is therefore paid exactly once.
  5. It is safe because the validation gate already proved sender.balance >= value before any mutation ran. transfer_value is only ever reached after that check passes, so the subtraction cannot underflow. This is the “validate before you mutate” discipline paying off: because affordability is proven up front, the mutation itself can stay trivially simple.