Skip to content

Revision · Smart Contracts & Solidity

This part took the abstractions the earlier parts gave you — the account model, the EVM, gas, and the state trie — and built the first thing on top of them that a stranger can trust without trusting you: a smart contract. We started with the deflationary truth that a contract is just an account with code in it, and ended with the full lifecycle of getting that code onto an address and calling it.

This page is a single-sitting recap. No new mechanisms, no exercises to reveal — just the spine of the part, restated so you can check that each vertebra is where you left it. Read it straight through; where a claim feels shaky, the linked page is the place to firm it up. Keep the book’s one question in view the whole way down: how do untrusting strangers agree on the state of a shared world computer — and what does it cost to run one? Every idea below is one half of that answer or the other.

Strip away the mysticism and a smart contract is three fields of an account record, bundled at one 20-byte address:

contract account @ 0xC0ntract… (one entry in the world-state map)
┌───────────────────────────────────────────────┐
│ code : immutable EVM bytecode ← set ONCE │
│ storage : slot → word map ← code r/w │
│ balance : ETH (in wei) ← can hold │
│ nonce : counter (for CREATE) │
└───────────────────────────────────────────────┘

The companion crate ethmini makes the point with a single method: the only thing that distinguishes a contract from a wallet is whether the code field is non-empty.

pub struct Account {
pub nonce: u64,
pub balance: u128, // native currency, in the smallest unit
pub storage: BTreeMap<u64, u64>, // the contract's persistent key/value memory
pub code: Vec<u8>, // the contract's bytecode
}
// The presence of code is the *only* thing that distinguishes a
// contract from a wallet.
pub fn is_contract(&self) -> bool {
!self.code.is_empty()
}

Three properties from What a Contract Actually Is carry the whole part:

  • It runs on every node. “Calling a contract” does not ask one server to run a program. It asks every full node to run the same bytecode against the same storage from the same starting state — and because the EVM is deterministic (no floating point, no wall clock, no true randomness), they all compute bit-for-bit the same new world state. That redundancy is the cost, and the reason gas must exist.
  • Its storage persists. A contract’s storage map is part of the world state. An SSTORE in one call is committed into the state and fingerprinted by the state root, so the next call — days later, from a different person — reads exactly what the last one left. Remove persistence and you have a calculator; add it back and you have a contract that remembers.
  • Its code is immutable. The bytecode is written once, at deploy, and there is no EVM instruction to edit it. This is what lets an untrusting stranger trust the code — it cannot be swapped out under them — and it is also why a bug is permanent. (The DAO, June 2016, is the standing lesson: an un-patchable reentrancy bug forced the hard fork that split Ethereum and Ethereum Classic.)

Only an externally owned account (a key-controlled wallet) can originate a transaction, because originating one requires a signature. Contracts are inert until called; they are the machinery, EOAs pull the trigger.

Nobody hand-writes the bytecode above. Solidity is a high-level language that the solc compiler lowers down to exactly the EVM bytecode you met in the EVM part — it is a convenience over the stack machine, never magic. A uint x; state variable becomes a storage slot; a function becomes a labelled chunk of bytecode reachable through the dispatcher; msg.sender and msg.value become reads of the call’s environment.

Solidity source solc artifacts
┌────────────────┐ compiler ┌──────────────────────────┐
│ contract C { │ ──────────► │ runtime bytecode (60 80…) │ → goes on-chain
│ uint x; │ │ init bytecode │ → runs once at deploy
│ function set… │ │ ABI (JSON) │ → stays OFF-chain
└────────────────┘ └──────────────────────────┘

The compiler emits three things that matter for the rest of the part: runtime bytecode (what ends up in the account’s code), init bytecode (the constructor logic that runs once during deployment and returns the runtime bytecode), and the ABI — a JSON description of the contract’s functions and events that lives off-chain and tells callers how to talk to the code.

The EVM has no notion of “functions.” A contract is one blob of code with one entry point. So how does transfer(...) reach different bytecode than balanceOf(...)? Two conventions, covered in The ABI and 4-Byte Function Selectors:

  1. The function selector is the first four bytes of the keccak256 hash of the function’s canonical signature — its name and parameter types with no spaces, e.g. transfer(address,uint256). The caller prepends those four bytes to the call data.
  2. The compiler builds a dispatcher at the top of the runtime bytecode: it reads the leading four bytes of the call data and compares them, one by one, against each function’s selector, jumping to the matching code.
selector = keccak256("transfer(address,uint256)")[0:4] = 0xa9059cbb
calldata: a9059cbb | 0000…recipient (32 bytes) | 0000…amount (32 bytes)
└ selector ┘└──────────── ABI-encoded arguments ────────────┘
dispatcher: if calldata[0:4] == 0xa9059cbb → jump to transfer
else if … == 0x70a08231 → jump to balanceOf
else → revert (or fallback)

Arguments after the selector are laid out by the ABI encoding rules (32-byte-aligned words, with a head/tail scheme for dynamic types like bytes and arrays). The selector is on-chain machinery; the ABI is the off-chain agreement — a stranger with only the ABI JSON can encode a correct call and decode the return value without ever seeing the source. Note the corollary: selectors are only four bytes, so collisions are possible and are a real security consideration, especially behind proxies.

The storage map is a flat address space of 2²⁵⁶ slots, each a 32-byte word, every slot initialized to zero. Storage Slot Layout is the rulebook Solidity uses to assign variables to slots — and getting it wrong is the difference between a correct contract and a fund-draining one.

  • Sequential slots. Plain state variables are assigned slots 0, 1, 2, … in declaration order.
  • Packing. Multiple variables that each fit in fewer than 32 bytes (e.g. a uint128 and two uint64) are packed into a single slot to save storage. Declaration order therefore has a gas cost: reorder fields and you can turn one slot into three.
  • Mappings and dynamic arrays are keccak-addressed. A mapping value for key k in the mapping declared at slot p lives at keccak256(k ‖ p). A dynamic array’s elements start at keccak256(p) and run sequentially, with the array’s length stored in slot p itself. This is why mappings can’t be iterated on-chain — there is no list of used keys, only a hash function that would tell you where a key lives if you already knew it.
contract Layout {
uint256 a; // slot 0
uint128 b; // slot 1 (packed…
uint128 c; // …with b, same slot 1)
mapping(address => uint256) bal; // slot 2 (the slot itself holds nothing)
}
// bal[addr] lives at keccak256(addr ‖ 2)

The cost consequence is the through-line of the whole part: a fresh storage write is one of the most expensive ordinary EVM operations (on the order of 20,000 gas for a zero-to-nonzero SSTORE under the 2024 gas schedule), and every slot you fill grows the state trie that every node must store and re-hash forever. Storage is where the money is.

Storage is expensive and, from outside the chain, hard to read — you would have to know every slot to reconstruct what happened. Events and Logs are the answer: a separate, cheaper channel the contract writes to with the LOG opcodes, committed in the receipts trie rather than the state trie.

The critical distinction: logs are not storage. On-chain contract code can never read past logs — they exist purely for off-chain consumers (indexers, wallets, dApp front-ends) to watch and reconstruct history. A log entry has two parts:

event Transfer(address indexed from, address indexed to, uint256 value);
emit Transfer(alice, bob, 100);
┌──────────────────────────────────────────────────────────────┐
│ topics[0] = keccak256("Transfer(address,address,uint256)") │ ← event signature
│ topics[1] = alice (indexed) │
│ topics[2] = bob (indexed) │ ← filterable
│ data = 0x…0064 (value = 100, NOT indexed) │ ← cheaper, not filterable
└──────────────────────────────────────────────────────────────┘
  • indexed parameters become topics (up to three, plus topics[0] for the event signature). Topics feed the block’s Bloom filter, so a light client can ask “did any block here touch Alice?” cheaply.
  • Non-indexed parameters go in data — cheaper per byte, but you cannot filter on them.

Choosing an event over a storage write is a deliberate trade: you give up on-chain readability (contracts can’t see logs) in exchange for a fraction of the gas. If the data only needs to be audited off-chain and never read by on-chain logic, it belongs in a log, not a slot.

None of the above matters until the bytecode is at an address. Deployment: CREATE and CREATE2 closes the loop. The key subtlety is that the code you send to deploy is not the code that ends up stored.

  • Init code (creation code) runs once. It is the constructor: it sets up initial storage and then returns the runtime code.
  • Runtime code is whatever the init code returns. That is what gets written into the new account’s code field and runs on every future call. Deployment pays gas per byte of runtime code stored (≈200 gas/byte under the 2024 schedule), which is why real contracts fight the 24 KB code-size limit.

Two opcodes create accounts, and they differ only in how the new address is derived:

CREATE address = keccak256(rlp(sender, nonce))[12:]
└─ depends on the creator's nonce → changes with every deploy, unpredictable
CREATE2 address = keccak256(0xff ‖ sender ‖ salt ‖ keccak256(init_code))[12:]
└─ depends only on YOUR chosen salt + the init code → deterministic

CREATE’s address depends on the sender’s nonce, so you cannot know it until you deploy. CREATE2 folds in a caller-chosen salt and the init-code hash, making the address fully determined before the contract exists — the basis of counterfactual deployment (fund or reference an address, deploy the code only when needed) that underpins many wallets and L2 patterns. Note the flip side: because the address is fixed by the init code, deploying different runtime code to the same CREATE2 address requires changing the init code or salt — the address is a commitment to the code.

The lifecycle, and why upgradeability is a pattern layered on top

Section titled “The lifecycle, and why upgradeability is a pattern layered on top”

Tie it together as The Contract Lifecycle does: a contract is authored in Solidity, compiled by solc to bytecode plus an ABI, deployed via CREATE/CREATE2 (init code runs, runtime code is stored), called through selectors that dispatch into its code and read/write its storage while emitting events, and eventually left immutable — or fronted by an upgrade pattern.

Because base-layer code is immutable, “upgradeability” is never a change to a contract; it is a proxy in front of it. A small, fixed proxy holds the storage and address users interact with, and forwards every call via delegatecall to a separate implementation contract whose address the proxy stores. To upgrade, you deploy a new implementation and point the proxy at it — the address and state users trust never move, but the logic behind them does. This buys mutability back at the cost of new hazards: the proxy and implementation must agree on storage layout (a mismatch corrupts state), and whoever can flip the implementation pointer effectively controls the contract. Upgradeability is a deliberate trade of trustless immutability for the ability to fix bugs.

Everything in this part is one of the two halves of the book’s question.

The “agree on shared state” half is answered by immutability plus determinism: the code is public and frozen, every node runs it identically, and the result is folded into a stateRoot any stranger can independently recompute. That is why a contract can be trusted without trusting its author — the trust is in the machine and the math, not the person.

The “what does it cost” half is answered by the cost lens you carried through every page: arithmetic is nearly free, logs are cheap, a fresh storage write is the giant (~20,000 gas and permanent state growth), and deployment pays by the byte.

Operation approx. gas (2024 schedule)
────────────────────────────────────────────────────────
ADD / stack arithmetic 3 ← basically free
SLOAD (read a warm storage slot) 100
LOG1 (emit an event, 1 topic, small data) ~ 750+
SSTORE (write a *fresh* slot, 0 → non-zero) 20,000 ← the giant
SSTORE (update an existing non-zero slot) 5,000
CREATE contract, per byte of runtime code 200 /byte
────────────────────────────────────────────────────────

Exact numbers shift with hard forks; the ranking is the durable insight. A contract that is cheap to write can be ruinously expensive to run, and seeing that gap — looking at any line of Solidity and asking what does this cost to execute, and what does it cost the world to remember? — is the skill this part exists to build.

  1. In one sentence, what is a smart contract in terms of an account record, and which single field’s presence makes an account a contract rather than a wallet?
  2. Trace the source-to-chain pipeline: name the artifacts solc emits, say which goes on-chain and which stays off-chain, and explain what a 4-byte selector is and why the EVM needs one at all.
  3. A mapping mapping(address => uint256) bal is declared at slot 5. Where does bal[addr] actually live, and why can’t a mapping be iterated on-chain? Separately, why does the declaration order of a struct’s fields have a gas cost?
  4. Events and storage both record data. State the two ways they differ (where the data lives, and who can read it later), and give one situation where you’d deliberately choose an event over a storage write.
  5. Contrast CREATE and CREATE2 address derivation, explain what “counterfactual deployment” means and which opcode enables it, and say why upgradeability has to be built as a proxy rather than by editing a contract’s code.
Show answers
  1. A smart contract is immutable EVM bytecode plus a persistent storage map plus a balance, sitting at an address and executed identically by every node. The distinguishing field is code: a non-empty code field makes the account a contract; an empty one makes it an externally owned account (a wallet, which also has no storage).
  2. solc emits runtime bytecode (stored in the account’s code, runs on every call — on-chain), init/creation bytecode (the constructor, runs once at deploy and returns the runtime code — on-chain only during deployment), and the ABI (a JSON interface description — stays off-chain). A selector is keccak256(canonicalSignature)[0:4], the first four bytes of the hash of e.g. transfer(address,uint256), prepended to the call data. The EVM needs it because it has no concept of named functions — a contract is one blob with one entry point, so the compiler’s dispatcher reads those four bytes and jumps to the matching code.
  3. bal[addr] lives at keccak256(addr ‖ 5) — the hash of the key concatenated with the mapping’s declared slot. A mapping can’t be iterated on-chain because there is no stored list of used keys; the hash tells you where a key would live only if you already know the key, so nothing enumerates them. Struct field order matters because Solidity packs sub-32-byte variables into shared slots in declaration order — reorder them badly and variables that could share one slot spread across several, and each extra slot is extra SSTORE/SLOAD gas plus permanent state growth.
  4. (a) Where the data lives: storage writes go into the state trie (part of the world state, fingerprinted by the state root); events write to the receipts trie as logs, which are not world state. (b) Who can read it later: on-chain contract code can read storage but can never read logs — logs exist only for off-chain consumers. You’d choose an event when the data only needs to be audited off-chain (e.g. a Transfer record for indexers and wallets) and no on-chain logic will ever read it back — the log is far cheaper than the equivalent storage write.
  5. CREATE derives the address from keccak256(rlp(sender, nonce)) — it depends on the deployer’s nonce, so it changes with every deploy and is unpredictable in advance. CREATE2 derives it from keccak256(0xff ‖ sender ‖ salt ‖ keccak256(init_code)) — it depends only on a caller-chosen salt and the init code, so the address is knowable before the contract exists. Counterfactual deployment is using/funding that pre-computed address and only deploying the code later; CREATE2 enables it. Upgradeability must be a proxy because base-layer bytecode is immutable — there is no instruction to edit a contract’s code — so a fixed proxy holds the address and storage and delegatecalls to a swappable implementation contract instead.