Yesterday you wrote a contract that worked. Today you write one that could be trusted with money, put a web page in front of it, and deploy it where anyone in the world can call it. The afternoon closes with the project briefing: the subjects, the decentralisation test, and the six weeks ahead. Groups start forming before you leave the room.
| Time | Block |
|---|---|
| 10:00 | Solidity for real: types, storage, and the cost of things |
| 10:35 | Rules and failures: require, custom errors, modifiers |
| 10:50 | Money: payable, sending ETH, and the first security lesson |
| 11:15 | Reuse: inheritance, interfaces, OpenZeppelin |
| 11:30 | Practical C: SimpleEscrow, from specification, with tests |
| 13:00 | Lunch |
| 14:00 | How a dApp is wired: browser, wallet, provider, node |
| 14:20 | viem and wagmi, by example |
| 14:50 | Practical D: a frontend for SimpleEscrow |
| 15:45 | Practical E: deploy to Base Sepolia and verify on Basescan |
| 16:15 | Project briefing |
| 17:00 | End — groups declared by Wednesday 22 October |
Morning, 10:00 – 13:00
Solidity for real: types, storage, and the cost of things
Solidity looks like JavaScript and behaves like nothing you have used. The gap is where the bugs live, so today starts with the type system and the memory model, at the level a working developer needs.
Integers. uint256 and int256, plus every width down to 8 bits. No
floats, no decimals: money is counted in its smallest unit — wei for ETH,
and each token declares its own — and any fraction is expressed as a ratio
of integers, multiplied before dividing. Since 0.8, overflow reverts; before
it wrapped, which is how a number of contracts lost everything. Division
truncates toward zero. Sixteen-bit integers do not save gas on their own —
the EVM word is 256 bits — but adjacent small fields in a struct are packed
into one slot, which does.
address and address payable. Twenty bytes. The payable variant
can receive ETH through .call{value: …}; the compiler makes you say which
you mean. address(this) is the contract’s own address; address(0) is the
zero address, conventionally “nobody”, and a frequent bug when a parameter
is forgotten.
bytes32, bytes, string. Fixed thirty-two bytes, dynamic bytes,
and a dynamic byte string that Solidity knows nothing about — no length in
characters, no comparison, no concatenation without a library. Hashes are
bytes32. Prefer bytes32 over string wherever a hash will do; it is
one slot instead of many.
mapping(K => V). A hash table with no size, no keys list, and no
iteration. Every key exists and reads as the zero value until written. You
cannot ask “which addresses have a balance”; you can only ask “what is this
address’s balance”. If you need the list, keep it separately — or, more
often, emit events and let an indexer keep it.
Arrays. T[] dynamic, T[n] fixed. Storage arrays can grow with
push. A loop over a storage array costs gas per element, per call, and
an array that anyone can grow is an array that anyone can make too
expensive to loop over. This is a denial-of-service pattern, not a
performance note.
struct and enum. A struct groups fields; an enum names states. A
Job with a Status enum is how you will model most of the project.
Storage, memory, calldata. Every reference type — arrays, structs,
strings, bytes — lives somewhere, and Solidity makes you say where.
storage is the contract’s persistent state, on chain, expensive to write.
memory is scratch space for the duration of a call, cheap, gone
afterwards. calldata is the read-only input of an external call, cheapest
of all. A function parameter that is only read should be calldata. A
local variable that is a copy of a struct should be memory; one that
should modify the stored struct must be storage — and Job memory job = jobs[id]; job.status = Done; is the bug where you edited a copy and
wondered why nothing changed.
constant and immutable. A constant is inlined at compile time. An
immutable is set once, in the constructor, and then inlined into the
deployed bytecode. Neither occupies storage; both are free to read. Every
parameter that never changes after deployment should be one of them.
Visibility. external for entry points called from outside; public
for functions that are called both from outside and internally, and for
state variables that should have a getter; internal for helpers and for
anything a subclass may use; private for what stays in this contract.
Note that private means “not callable” — it does not mean “not visible”.
Everything in a contract’s storage is readable by anyone who asks a node,
whatever the keyword says. There are no secrets on a chain.
Rules and failures: require, custom errors, modifiers
A transaction either completes or reverts, and a revert undoes everything the transaction did — state changes, ETH transfers, events. This is atomicity at the level of the whole call, and it means you can check conditions anywhere and never leave a half-applied change.
require(cond, "message") reverts with a string. It works and everyone
understands it. Custom errors are the modern form:
error NotClient(address caller);
error WrongStatus(Status expected, Status actual);
if (msg.sender != job.client) revert NotClient(msg.sender);
They are cheaper, they carry structured data, and viem decodes them into something a frontend can display. Use them.
A modifier factors a check out of several functions:
modifier onlyClient(uint256 jobId) {
if (msg.sender != jobs[jobId].client) revert NotClient(msg.sender);
_;
}
function release(uint256 jobId) external onlyClient(jobId) { … }
The _ is where the function body goes. Modifiers are for access checks
and state checks; a modifier that does real work is a function in disguise.
Design habit: check everything at the top, change state in the middle, talk to other contracts at the end. The next section says why.
Money: payable, sending ETH, and the first security lesson
A function marked payable can receive ETH with the call; msg.value is
how much. A function not marked payable reverts if ETH is sent to it.
A contract can also receive plain transfers if it defines a receive() external payable function; without one, sending ETH to it reverts.
Sending ETH out is where the language’s history shows. There are three
ways: transfer, send, and call. The first two forward a fixed 2,300
gas, which was once considered safe and is now considered broken — it is
not enough for a recipient that is a contract with any logic in its
receive. Use call:
(bool ok, ) = recipient.call{value: amount}("");
if (!ok) revert TransferFailed();
And now the first security lesson, which is the most famous bug in the
field. When you call an address, if it is a contract, its code runs —
before your function continues. If that code calls back into your
contract, and your contract has not yet recorded that the payment was made,
it will pay again. This is reentrancy, it is how The DAO lost the
equivalent of sixty million dollars in 2016, and it is still found in
audits every year.
The fix is a discipline, not a library: checks, effects, interactions.
Check the conditions; apply every state change; and only then interact
with other addresses. Written that way, the callback finds the state
already updated and the second withdrawal fails. OpenZeppelin’s
ReentrancyGuard adds a lock as a belt to the braces; use both.
The other habit that follows from this is pull over push. Instead of sending funds to an address when something happens, record that the address is owed, and let it withdraw. A failing recipient then blocks only its own withdrawal rather than the whole transaction — an escrow that cannot release because the freelancer’s receive function reverts is an escrow that holds the client’s money hostage.
Reuse: inheritance, interfaces, OpenZeppelin
Contracts inherit: contract Escrow is Ownable, ReentrancyGuard. A
subclass gets the parent’s state and functions, can override virtual
ones, and calls the parent’s constructor in its own. Multiple inheritance
is allowed and linearised; keep hierarchies shallow.
An interface declares functions without bodies. It is how you call a
contract you did not write — declare IERC20 with transfer and
balanceOf, cast an address to it, call it. The ABI is all the EVM
needs; the interface is how Solidity types it.
OpenZeppelin Contracts is the standard library the ecosystem actually
uses: audited implementations of the token standards, access control,
reentrancy guards, pausing, timelocks, and a great deal else. You will use
Ownable — one owner address, an onlyOwner modifier, a transfer of
ownership — and ReentrancyGuard today, and ERC20, ERC721,
AccessControl in the project. Install it and import from it; do not copy
it into your repository, and do not write your own ERC-20.
npm install @openzeppelin/contracts
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
Practical C: SimpleEscrow, from specification, with tests
Ninety minutes. This is a cut-down version of the escrow subject, and it is what the afternoon’s frontend will talk to. The specification:
A
SimpleEscrowholds one job at a time between a client and a freelancer. The client creates the job by naming the freelancer and sending the payment with the creation. The freelancer marks the job delivered. The client then releases the payment to the freelancer, or, if the job is not yet delivered, cancels and is refunded. Once released or cancelled, the job is closed and a new one may be created. Every transition emits an event. Every rule that can reject a call has a custom error.
The states, as an enum: None, Funded, Delivered, Released,
Cancelled. Draw the transitions before writing code; then each function
is a check on the current state, a change to the next, and an event.
Requirements for the test suite, which is the real deliverable of this practical:
- The full happy path: create, deliver, release — with the freelancer’s
balance checked before and after, using
publicClient.getBalance. - The cancel path, with the client’s refund checked.
- Every wrong caller: the freelancer trying to release, the client trying to deliver, a stranger trying anything.
- Every wrong state: releasing before delivery, delivering twice, cancelling after delivery.
- Creating with zero value reverts.
- Creating while a job is open reverts.
Use ReentrancyGuard on release and cancel, and pay with call. Then,
if there is time, write the attack: a small Attacker contract whose
receive calls back into release, deployed as the freelancer, to prove
that without the guard and without checks-effects-interactions the escrow
pays twice — and that with them, it does not. That test is worth more than
all the others combined, and its shape is what you will reuse in the
project.
Afternoon, 14:00 – 17:00
How a dApp is wired: browser, wallet, provider, node
A decentralised application is, on the frontend side, a web page that talks to a chain. The path has four hops, and understanding them explains every error you will see.
The browser runs your page — plain React, no server required. It can
read the chain directly by making JSON-RPC calls to a node, exactly as you
did with curl yesterday. For reads, that is the whole story.
The wallet is a browser extension that holds keys. It injects an
object into every page — window.ethereum — that speaks a small standard,
EIP-1193: “request this RPC method”. Your page asks the wallet to sign and
send a transaction; the wallet shows the user a confirmation; the user
approves; the wallet signs with a key your page never sees, and sends it
on. Your page never touches a private key. That is the entire security
model of the frontend, and it is why “connect wallet” is the first button
of every dApp.
The provider is the RPC endpoint the wallet — or your page — sends
requests to. For the local Hardhat node it is localhost:8545; for Base
Sepolia it is the URL from your RPC provider, or the public one. The
wallet has its own provider per network; your page should have its own for
reads, so that it works before a wallet is connected.
The node does what nodes do. The transaction propagates, is included in a block, and your page finds out by polling for the receipt or by watching for the event the contract emitted.
Two consequences to internalise. First, there is no backend in the loop: the frontend, the wallet, and the chain are the whole system, and any server you add is a choice, with trust implications you must write down. Second, the user signs everything: every write is a wallet pop-up, and a design that needs ten transactions to do one thing is a design nobody will use.
viem and wagmi, by example
viem is the TypeScript library for talking to an EVM chain. Two kinds
of client: a PublicClient for reads, backed by an RPC URL; a
WalletClient for writes, backed by the injected wallet. Contracts are
addressed by ABI — the JSON description of their functions and events that
Hardhat produces in artifacts/ — and viem uses the ABI’s types to type
your calls, so that read.count() returns a bigint and
write.create([freelancer], { value }) refuses the wrong arguments.
import { createPublicClient, http, getContract } from "viem";
import { baseSepolia } from "viem/chains";
import { abi } from "./SimpleEscrow.abi";
const client = createPublicClient({ chain: baseSepolia, transport: http() });
const escrow = getContract({ address, abi, client });
const status = await escrow.read.status();
wagmi is the React layer on top: hooks that manage the wallet connection and wrap viem for you.
useAccount()— the connected address and chain, if any.useConnect()/useDisconnect()— the wallet button.useReadContract({ address, abi, functionName })— a read, cached and refreshed.useWriteContract()— a write; returns a function that pops the wallet and a transaction hash when the user approves.useWaitForTransactionReceipt({ hash })— pending, then confirmed or failed.useWatchContractEvent({ address, abi, eventName, onLogs })— a subscription to events, which is how the page updates when someone else acts.useSwitchChain()— because the user’s wallet will be on the wrong network, and your page has to notice and offer to fix it.
The transaction lifecycle in the UI, which every dApp gets wrong the first
time: the user clicks; the wallet pops; the user may reject — handle
it, it is not an error; the wallet returns a hash — now the transaction is
pending, and the page should say so and disable the button; the
receipt arrives — confirmed, refresh the reads; or it arrives with
status: "reverted" — show the reason, which viem decodes from the custom
error if you give it the ABI. A page that shows “success” when the wallet
returned a hash is a page that lies a few seconds before the chain says
otherwise.
Practical D: a frontend for SimpleEscrow
Fifty-five minutes. A starter is provided — a Vite React project with wagmi configured for the local Hardhat network and Base Sepolia, a wallet button, and an empty page. Fill it in:
- Show the job: its status, client, freelancer, amount. Read from the
contract, refresh when the
Statusevent fires. - Connect a wallet, and show which role the connected address has — the client, the freelancer, or neither — and enable only the buttons that role may press. This is not security; the contract does that. It is courtesy.
- The four actions as buttons, each following the lifecycle above: click, pending, confirmed or reverted with the decoded reason.
- Two browser profiles — or two browsers — one as client, one as freelancer, against your local node. Run the happy path. Run a failure and see the custom error come through.
The point of the practical is not the CSS. It is seeing a state change on your screen because of a transaction someone else signed, and seeing a revert reason arrive from a contract rather than from a server.
Practical E: deploy to Base Sepolia and verify on Basescan
Thirty minutes, and the moment the module has been building to.
Configuration. In hardhat.config.ts, add the network:
import { configVariable } from "hardhat/config";
networks: {
baseSepolia: {
type: "http",
chainType: "op",
url: configVariable("BASE_SEPOLIA_RPC_URL"),
accounts: [configVariable("BASE_SEPOLIA_PRIVATE_KEY")],
},
},
configVariable reads from Hardhat’s encrypted keystore rather than from
a plaintext .env. Set the two values:
npx hardhat keystore set BASE_SEPOLIA_RPC_URL
npx hardhat keystore set BASE_SEPOLIA_PRIVATE_KEY
The private key is your development wallet’s — export it from MetaMask, account details, “show private key”. It is stored encrypted on your disk and never in the repository. This is the first and last time in the module a private key will be handled by hand; the project’s deployment should work exactly this way, and a private key in a committed file is the fastest way to fail the security question at the defence.
Deploy.
npx hardhat ignition deploy ignition/modules/SimpleEscrow.ts --network baseSepolia
It takes a few seconds — this is a real network. Ignition writes the
address under ignition/deployments/chain-84532/; commit that directory,
it is the record of what you deployed where.
Verify. Add your Etherscan API key to the keystore, and to the config:
verify: {
etherscan: { apiKey: configVariable("ETHERSCAN_API_KEY") },
},
npx hardhat verify --network baseSepolia 0xYOUR_CONTRACT_ADDRESS
Open the address on Basescan. The source is there, the “Read Contract” and “Write Contract” tabs work, and anyone in the world can see exactly what your contract does. That is what verification is for: a contract whose source is not verified is asking users to trust bytecode.
Point the frontend at it. Change the address and the chain in the starter; switch MetaMask to Base Sepolia; run the happy path with a neighbour as freelancer. Then watch it on the explorer.
If the public RPC is slow or rate-limited, use your provider’s URL. If you run out of testnet ETH, ask on Teams. If the verification fails with a message about bytecode, check that the compiler settings match — the usual cause is an optimiser setting changed after deployment.
Project briefing
Forty-five minutes, on the project page, the decentralisation test, and the three subjects: escrow, treasury, credentials. What is covered, in order:
- The brief: what every project must contain, and what will not work.
- The test: the one question, the five properties, and the shape of the justification you will write.
- The three subjects, and how to propose your own.
- The timeline: groups and subjects declared by Wednesday 22 October; intermediate defence 7 November; freeze 27 November; final defence 28 November or 5 December.
- How the defences work — and, in particular, that every member answers for every part.
- Questions.
Groups form now. Three people, M1 and M2 mixed as you like. If you do not have a group by the end of the session, say so on Teams and one will be found.
Homework
- Finish the escrow tests, especially the reentrancy attack test. The reference solution follows after the weekend.
- Form your group and pick a subject. Read all three subject pages before choosing; they differ more in what they teach than in what they build.
- Deploy something for the project by the end of next week: a contract with two functions, verified on Base Sepolia, and a page that reads it. You will be asked about it on Day 3.
Vocabulary from today
calldata, memory, storage, immutable, constant; custom error,
modifier, revert, atomicity; payable, msg.value, receive, call,
reentrancy, checks-effects-interactions, pull payment; inheritance,
interface, ABI, OpenZeppelin, Ownable, ReentrancyGuard; EIP-1193,
injected provider, public client, wallet client, receipt, pending,
confirmed; keystore, verification, Basescan; group, subject,
decentralisation test.