Day 24 · Smart Contracts
Day 23 built the engine; today we drive it. A smart contract sounds
mystical, and the demystification is the whole point of this page: a contract is a few bytes of bytecode
sitting in an account, run by a gas-metered loop against a storage map. Nothing more. We’ll deploy one
(store its code in a freshly created account), call it (run that code), and watch its storage
remember the result between calls. The contract we build is the “hello world” of stateful contracts — a
counter — and by the end you’ll have run it end-to-end and seen the world’s state root move each time.
Deploy: code becomes an account
Section titled “Deploy: code becomes an account”Deploying a contract is just a transaction whose effect is “create a new account and put this bytecode in
its code field.” Two questions need answers: what address does the new contract get, and where does
the code come from.
TxKind::Deploy { code, value } => { let addr = contract_address(tx.from, tx.nonce); // deterministic address if *value > 0 { self.transfer_value(tx.from, addr, *value); } self.account_mut(addr).code = code.clone(); // the account now has code Ok(Receipt { status: Success, created: Some(addr), .. }) }The address is derived, not chosen, from the creator and the creator’s nonce:
pub fn contract_address(creator: Address, nonce: u64) -> Address { let digest = sha256(creator.0 ++ nonce.to_be_bytes()); Address(digest[12..32].try_into().unwrap()) // last 20 bytes }Deriving the address from (creator, nonce) has a lovely property: because each transaction bumps the
nonce, the same account deploying twice gets two different addresses, deterministically. Anyone can
predict where a contract will land before it’s deployed, and nobody can collide with an existing one.
The counter contract
Section titled “The counter contract”Here is the entire contract, in assembly and in ethmini’s Op enum. Its whole job: keep a number in
storage slot 0 and add one each time it’s called.
PUSH 0 ; key SLOAD ; stack: [ count ] ← read persistent storage[0] PUSH 1 ADD ; stack: [ count + 1 ] PUSH 0 ; key (on top, the way SSTORE wants it) SSTORE ; storage[0] = count + 1 ← write it back, persistently STOP pub fn counter_code() -> Vec<u8> { assemble(&[ Op::Push(0), Op::SLoad, // load storage[0] Op::Push(1), Op::Add, // + 1 Op::Push(0), Op::SStore, // store back Op::Stop, ]) }The two storage opcodes are what make this a contract and not a throwaway calculation:
SLOADreads slot 0. On the very first call the slot is unset, and an unset slot reads as 0 — so the counter starts at 0 with no initialisation code. (Real EVM has the same “unset reads as zero” rule.)SSTOREwrites the new value back into the account’sstoragemap. This is the line that persists: when the call commits, that map is saved into the world state, so the next call’sSLOADsees it.
Call: run the code against its storage
Section titled “Call: run the code against its storage”A Call transaction runs the target’s code. The state machine clones the contract’s storage, hands it to
the VM, and commits the result only if the VM halts cleanly:
TxKind::Call { to, value, gas_limit } => { if *value > 0 { self.transfer_value(tx.from, *to, *value); }
let target = self.account_mut(*to); if !target.is_contract() { // no code? it's just a transfer return Ok(Receipt { status: Success, .. }); } let (code, storage) = (target.code.clone(), target.storage.clone());
match vm::run(&code, &storage, *gas_limit) { Ok(exec) => { // clean halt → COMMIT the new storage self.account_mut(*to).storage = exec.storage; Ok(Receipt { status: Success, gas_used: exec.gas_used, .. }) } Err(reason) => Err(Reverted { // threw → caller reverts everything gas_used: *gas_limit, reason, }), } }Notice the symmetry with Day 23: the VM mutated a clone; here the state
machine also works on a clone of storage and commits it only on Ok. On Err, execute returns
Reverted, and (from Day 22) apply rolls the whole world back to its snapshot — keeping only the nonce
bump. Persistence and revert are the same mechanism viewed from two levels: own a copy, install it on
success, drop it on failure.
End to end
Section titled “End to end”The integration test (tests/contract.rs) is the contract’s life story:
let mut state = WorldState::genesis(&[(alice, 1_000)]);
// deploy let deploy = Transaction::deploy(alice, state.nonce(&alice), counter_code(), 0); let counter = state.apply(&deploy)?.created.unwrap(); assert_eq!(state.storage(&counter, 0), 0); // starts at 0
// call three times → 1, 2, 3 for expected in 1..=3 { let call = Transaction::call(alice, state.nonce(&alice), counter, 0, 100_000); state.apply(&call)?; assert_eq!(state.storage(&counter, 0), expected); } assert_ne!(state.state_root(), root_after_deploy); // state movedRun cargo run and you’ll see it live:
== call counter x3 == status Success, gas used 312, count now 1 status Success, gas used 312, count now 2 status Success, gas used 312, count now 3The number 3 does not live in any program’s memory — cargo run starts fresh each time. It lives in the
contract’s storage, in the world state, fingerprinted by the state root. That is what “a program that
lives on the blockchain” means, concretely: state that outlives every process because it is the chain.
What we left out — and what it costs to leave in
Section titled “What we left out — and what it costs to leave in”Our contracts are real but spare. Three honest omissions, each a door into how dangerous real contracts are:
- No calldata / functions. Our counter does one thing. Real contracts dispatch on calldata (an
argument blob) to pick among many functions. Same VM, plus an
ABIdecoding convention. - No contract-to-contract calls. Real EVM has
CALL, letting one contract invoke another mid-execution. That single feature is the source of reentrancy, the most infamous bug class in the space. - Immutability. Once deployed, code can’t be edited. A bug isn’t a patch away; it’s permanent. “Code is law” is exhilarating until the code is wrong.
The thread
Section titled “The thread”What does building this force you to understand, and what is Rust’s compiler protecting you from? Building deploy-and-call forces the realisation that a contract is just bytecode plus a storage map living in an account — and that “persistent” and “revertible” are the same ownership trick (own a clone; commit on success, drop on failure) applied at two levels, which Rust’s move semantics make the path of least resistance. And the project’s question — what problem did Ethereum solve? — is finally tangible: you deployed a program to a shared world computer and called it, and its memory survived in the chain’s state. That is programmable money in miniature. Tomorrow we close the loop on why gas must exist at all, and how Ethereum decides whose version of this state is canonical.
→ Next: Day 25 · Gas & Proof-of-Stake · Prev: Day 23 · A Tiny VM · Back to the Project 6 overview
Check your understanding
Section titled “Check your understanding”- A contract address is derived, not chosen. From what is it derived, and what useful property does deriving it from the creator’s nonce give you?
- The counter contract has no initialisation step, yet its first call correctly produces
1. What rule about reading an unset storage slot makes that work? - Which two opcodes make the counter a stateful contract rather than a throwaway calculation, and what
does each one do with the account’s
storagemap? - Trace what
applycommits versus discards when aCall(a) halts cleanly and (b) runs out of gas. How is this the same “own a clone, commit on success” pattern as the VM itself? - The DAO exploit combined three properties this page introduced. Name them and explain how each contributed to the severity of the incident.
Show answers
- It’s derived from the creator’s address and the creator’s nonce (
sha256(creator ‖ nonce)[12:]here;keccak256(rlp([sender, nonce]))[12:]in real Ethereum). Because each transaction bumps the nonce, the same account deploying repeatedly gets distinct, predictable addresses with no collisions — you can even compute a contract’s address before deploying it. - An unset storage slot reads as 0 (
SLOADof a missing key returns 0). So the firstSLOADof slot 0 yields 0,ADD 1makes 1, andSSTOREwrites it — no initialiser needed. SLOADreads slot 0 from the account’sstoragemap (the current count);SSTOREwrites the incremented value back into that map. TheSSTOREis what persists — when the call commits, the map is saved into the world state, so the next call’sSLOADsees it.- (a) On a clean halt the VM returns
Ok(Execution)andapplycommits the new storage into the account (and keeps the value transfer); the receipt isSuccess. (b) On out of gas the VM returnsErr,executereturnsReverted, andapplyrestores its snapshot — discarding the storage write and the value transfer, keeping only the nonce bump. Both levels use the same pattern: work on a clone, install it only onOk, drop it onErr. - Persistent mutable state (the attacker manipulated the contract’s stored balances via reentrancy); running untrusted code (a malicious contract’s fallback re-entered the withdrawal mid-transaction); and immutability (the buggy code couldn’t be patched, so the only fixes were a hard fork or living with the loss — which split the chain into ETH and Ethereum Classic).