Solidity smart contracts, one concept per page
The twenty-four steps that take you from 'I understand blockchains' to a deployed contract you wrote yourself.
A diagram of the mechanism, the classic mistake, and one thing to go try. That's a page.
Solidity smart contracts, one concept per page
The twenty-four steps that take you from 'I understand blockchains' to a deployed contract you wrote yourself.
Set in Space Grotesk, Inter and JetBrains Mono (SIL Open Font License).
Mechanisms checked against the Solidity documentation at docs.soliditylang.org (contracts, types, control structures, security considerations, units and global variables, current 0.8.x semantics), ethereum.org's developer documentation (token standards, smart contracts), and the OpenZeppelin Contracts 5.x documentation (Ownable, AccessControl, ERC20, ReentrancyGuard). All example code is written for Solidity 0.8.x, where arithmetic is checked by default and custom errors are available. This book builds on the canonical definitions in Blockchain basics, one concept per page.
Educational information only, not financial advice. No investment advice, no price talk, no token recommendations. This book explains mechanisms; it does not tell you to buy anything.
Your purchase is for personal use only. You do not have redistribution rights: please do not share, resell, or republish this book or its pages.
© 2026 Steve Hodgkiss. All rights reserved. Personal use only; no redistribution rights.
Edition 1.0 · stevehodgkiss.net
Contents
Your first contract
Zero installs, first deploy. Five pages that get you from 'I know what a contract is' to a compiled, deployed contract on a testnet, and explain what a contract and its variables actually ARE.
- 01Remix: start with zero setup
- 02State variables: the contract's memory
- 03Functions and visibility
Remix is the browser IDE the Solidity docs use in their own examples. Edit, compile and deploy a contract from one tab, with nothing installed.
Remix: start with zero setup
Let's say every tutorial opens with installing three tools. Open remix.ethereum.org instead: editor, compiler, deploy, one tab.
The misconception is that real developers install a toolchain first. Remix is not a toy.
The official docs put an "open in Remix" button under every example.
First deploy beats perfect setup.
Open remix.ethereum.org, compile and deploy the default Storage contract to the Remix VM, then press retrieve.
A state variable is stored on the chain and keeps its value between calls. A local variable inside a function is rebuilt and thrown away on every call.
State variables: the contract's memory
Let's say your counter resets on every call. Values at the top live in storage: transaction-written, node-kept.
Here a storage write is a transaction: it costs gas and it sticks
Remix shows the split as button colour: blue reads free, orange writes cost gas
Want it remembered? Top of the contract.
Read a public uint on Remix's blue button, set it on the orange one, then read it again.
Visibility decides who can call a function: public and external to anyone, internal to the contract and its children, private to the contract alone. None of them hide data from the world.
Functions and visibility
Let's say a function should only run for your contract's own code. Say it in the declaration: public, external, internal or private.
Private does not mean secret. Private state is still visible to the whole world
public also builds a free getter for state variables, so explorers can read them
Visibility is who may call, never what anyone may see.
Declare a private uint in Remix, deploy, then read it anyway on an explorer with Get Storage at, slot 0. Private is not secret.
A mapping turns one key into one value slot: balances of this address, scores of that player. It cannot list or count its keys; it only answers lookups.
Mapping: keys to drawers
Let's say every player needs a score and if-chains feel wrong. Declare mapping(address => uint) scores: hand it an address, it hands you that address's drawer.
A mapping doesn't know its own contents. It cannot list keys or count itself, so pay-everyone loops don't work.
A key never written doesn't error; it reads as zero. Treat zero as no entry, or mark entries explicitly.
A mapping answers. It never narrates.
Write mapping(address => uint) public balances in Remix, set two addresses, read each back. Two drawers, no others.
Logic and safety
Contracts hold value, so the guardrails matter. Five pages on require's all-or-nothing transaction, the cheaper custom error, modifiers as reusable gates, the Ownable pattern, and the reentrancy trap every audit looks for first.
- 01require: the gate that reverts
- 02The reentrancy trap
require is a guard clause at the top of a function. If the condition fails, the whole transaction reverts: every state change in the call is rolled back as if it never ran.
require: the gate that reverts
Let's say only the owner may change a number and someone else tries. require(msg.sender == owner) is the gate: fail it and the transaction reverts.
No partial success. Every change in the call unwinds.
Your state comes back; the gas burned does not.
One failed check, one clean undo.
Add require(msg.sender == owner) to a setNumber function, then call it from a second account and read the revert reason.
Send ETH before updating the balance and the receiving contract can call back in, see the stale balance, and withdraw again and again. Checks-Effects-Interactions fixes the order.
The reentrancy trap
Let's say the DAO taught the ecosystem this in 2016 and audits still open with it. Any transfer hands control to the receiver's code, which can call you back before your next line runs.
The buggy order: send the ETH, then write the balance. The callback reads the stale balance and drains the vault.
The docs' fix is Checks-Effects-Interactions: check inputs, write all state, then interact. Zero the share before the send.
Checks, effects, interactions. Every time.
Take the docs' buggy withdraw example and move shares[msg.sender] = 0 above the call. That's CEI.
Money and tokens
Contracts can hold ETH and issue tokens, and both have well-worn rails. Four pages on payable and receive, the three ways to send ETH and which to use, what the ERC-20 standard actually defines, and why you import OpenZeppelin instead of writing your own.
- 01payable and receive: the ETH door
- 02ERC-20: the standard in plain words
A plain transfer to a contract without a payable path reverts. Functions marked payable accept ETH, and receive() external payable is the door for transfers with no message at all.
payable and receive: the ETH door
Let's say someone sends ETH and it comes straight back. The door needs a sign: functions marked payable, or receive() for plain transfers.
Without either, the contract cannot receive ETH and the transfer throws.
Inside, the amount sits in msg.value, the total in address(this).balance.
No payable, no entry.
Add receive() external payable {} to a Remix contract, deploy, then send it 0.1 Sepolia ETH from the low-level Transact panel.
ERC-20 defines the token interface: supply, balances, transfers, and the approve/transferFrom allowance flow, with Transfer and Approval events. Every wallet speaks it.
ERC-20: the standard in plain words
Let's say every app inventing its own token commands meant nothing works together. ERC-20 is the agreed list from 2015: these functions, these events, these meanings.
Your contract never holds the tokens. The token contract keeps the whole ledger, including your row in its mapping.
The clever part is approve: you let a spender use part of your row, and transferFrom is how they spend it.
A token is a contract with a shared address book.
On any explorer, open a token's Contract tab and read down the ABI: transfer, approve, allowance. You're reading the standard.