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.
What a contract actually is
Section titled “What a contract actually is”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
storagemap is part of the world state. AnSSTOREin 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.
From Solidity source to on-chain bytecode
Section titled “From Solidity source to on-chain bytecode”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 ABI and 4-byte selectors
Section titled “The ABI and 4-byte selectors”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:
- The function selector is the first four bytes of the
keccak256hash 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. - 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.
Storage slot layout
Section titled “Storage slot layout”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
uint128and twouint64) 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
kin the mapping declared at slotplives atkeccak256(k ‖ p). A dynamic array’s elements start atkeccak256(p)and run sequentially, with the array’s length stored in slotpitself. 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.
Events and logs: the cheap audit channel
Section titled “Events and logs: the cheap audit channel”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 └──────────────────────────────────────────────────────────────┘indexedparameters become topics (up to three, plustopics[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-
indexedparameters go indata— 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.
Deployment: CREATE and CREATE2
Section titled “Deployment: CREATE and CREATE2”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
codefield 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 → deterministicCREATE’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.
The thread: cost, determinism, and trust
Section titled “The thread: cost, determinism, and trust”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 freeSLOAD (read a warm storage slot) 100LOG1 (emit an event, 1 topic, small data) ~ 750+SSTORE (write a *fresh* slot, 0 → non-zero) 20,000 ← the giantSSTORE (update an existing non-zero slot) 5,000CREATE 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.
Check your understanding
Section titled “Check your understanding”- 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?
- Trace the source-to-chain pipeline: name the artifacts
solcemits, 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. - A mapping
mapping(address => uint256) balis declared at slot 5. Where doesbal[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? - 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.
- Contrast
CREATEandCREATE2address 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
- 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-emptycodefield makes the account a contract; an empty one makes it an externally owned account (a wallet, which also has no storage). solcemits runtime bytecode (stored in the account’scode, 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 iskeccak256(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.bal[addr]lives atkeccak256(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 extraSSTORE/SLOADgas plus permanent state growth.- (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
Transferrecord for indexers and wallets) and no on-chain logic will ever read it back — the log is far cheaper than the equivalent storage write. CREATEderives the address fromkeccak256(rlp(sender, nonce))— it depends on the deployer’s nonce, so it changes with every deploy and is unpredictable in advance.CREATE2derives it fromkeccak256(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 anddelegatecalls to a swappable implementation contract instead.