Skip to content

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.

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.

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:

  • SLOAD reads 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.)
  • SSTORE writes the new value back into the account’s storage map. This is the line that persists: when the call commits, that map is saved into the world state, so the next call’s SLOAD sees it.

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.

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 moved

Run 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 3

The 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 ABI decoding 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.

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

  1. 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?
  2. 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?
  3. Which two opcodes make the counter a stateful contract rather than a throwaway calculation, and what does each one do with the account’s storage map?
  4. Trace what apply commits versus discards when a Call (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?
  5. The DAO exploit combined three properties this page introduced. Name them and explain how each contributed to the severity of the incident.
Show answers
  1. 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.
  2. An unset storage slot reads as 0 (SLOAD of a missing key returns 0). So the first SLOAD of slot 0 yields 0, ADD 1 makes 1, and SSTORE writes it — no initialiser needed.
  3. SLOAD reads slot 0 from the account’s storage map (the current count); SSTORE writes the incremented value back into that map. The SSTORE is what persists — when the call commits, the map is saved into the world state, so the next call’s SLOAD sees it.
  4. (a) On a clean halt the VM returns Ok(Execution) and apply commits the new storage into the account (and keeps the value transfer); the receipt is Success. (b) On out of gas the VM returns Err, execute returns Reverted, and apply restores 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 on Ok, drop it on Err.
  5. 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).