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 and subjects declared within the week |
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. address payable is
what .transfer() and .send() require; .call{value: …}, which is what
you should use, works on a plain address, so you will meet payable
mostly as a cast an older interface makes you write. 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, or a payable fallback; without either, an
ordinary transfer to it reverts. It can still be forced to hold ETH:
selfdestruct in another contract sends a balance to any address,
receive or not, and an address can be funded before a contract exists
at it. Never write a rule that assumes address(this).balance equals
what your contract has accounted for.
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 {
constructor() Ownable(msg.sender) {}
}
A subclass gets the parent’s state and functions, can override virtual
ones, and calls the parent’s constructor in its own, as Ownable(msg.sender)
does here. Since OpenZeppelin Contracts 5, Ownable takes the initial
owner as an argument, and a contract that inherits it without passing one
does not compile. 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
That installs the 5.x line, which every example on this site is written against. Older tutorials use 4.x, which differs in exactly the kind of detail that stops a compile.
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 may not be the zero address. The freelancer marks the job delivered. The client then releases the payment, which the freelancer withdraws; or, if the job is not yet delivered, cancels, and withdraws the refund. Nothing is ever pushed to anyone:releaseandcancelrecord what an address is owed, andwithdrawpays the caller what it is owed. 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.
withdraw is not a transition: it reads a balance, owed[msg.sender],
and pays it out, whatever state the job is in. That is the pull payment
from the lecture, and it is why a freelancer whose receive reverts
blocks only its own withdrawal, and never the escrow.
Two things this specification leaves open, on purpose, and the reference solution leaves open too. What happens if the client simply never answers after delivery? And who may start a job, given that the contract holds one at a time and anyone may create one, for one wei, and never finish it? You do not have to fix either today. You do have to be able to answer both, and the real escrow subject’s traps section is where the answers start.
Requirements for the test suite, which is the real deliverable of this practical:
- The full happy path: create, deliver, release, withdraw, with the
freelancer’s balance checked before and after the withdrawal, using
publicClient.getBalance, and the escrow’s balance checked at each step. Released means owed, not paid. The withdrawer pays gas, so the balance rises by less than the amount: add backreceipt.gasUsed * receipt.effectiveGasPricebefore comparing, or the test fails by a few thousand wei with a diff nobody can read. - The cancel path, with the client’s refund withdrawn and checked.
- Every wrong caller: the freelancer trying to release, the client trying to deliver, a stranger trying anything, and a withdrawal by an address that is owed nothing.
- Every wrong state: releasing before delivery, delivering twice, cancelling after delivery, withdrawing twice.
- Creating with zero value reverts, and so does naming the zero address as freelancer.
- Creating while a job is open reverts.
In withdraw, zero the balance before you pay it, pay with call, and
put ReentrancyGuard’s nonReentrant on it as well. Then, if there is
time, write the attack: a small Attacker contract, hired as the
freelancer, whose receive calls withdraw again while the first
withdrawal is still running. Against a copy of your escrow with the
zeroing moved after the call and the guard removed, and with a second
job’s money sitting in the contract, it drains the contract; against your
real one it reverts. That test is worth more than all the others combined,
and its shape is what you will reuse in the project.
Notice what the attacker needs: a payout it can trigger itself. An escrow
whose only payout is a release that the client alone may call cannot be
re-entered into release by the freelancer, whatever the order of its
statements; access control is the cheapest defence there is. The
freelancer’s code still runs during the transfer, and can still call your
other functions while this one is half-finished, which is why
checks-effects-interactions is a rule about the whole contract, not about
one function. The pull design hands
that payout back to the freelancer on purpose, because it is the right
design, and then defends it properly. The reference solution, the
vulnerable copy, the attacker and the tests are in the
Day 3 material, runnable.
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 security model of the frontend in one sentence, 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. In wagmi that means
a transports entry per chain in the config, and a chainId on every
useReadContract and useWatchContractEvent. Without it, wagmi reads
whichever chain is current, which before a wallet connects is the first
one in your list, and the page shows nothing, silently.
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.
Two details the paragraph above hides. First, “viem decodes the custom
error” is true, and the decoded error is not where you first look: the
hook’s error is a wrapper, and the ContractFunctionRevertedError
carrying the name and arguments is somewhere down its cause chain. walk
finds it:
import { BaseError, ContractFunctionRevertedError } from "viem";
function reason(error: unknown): string {
if (!(error instanceof BaseError)) return String(error);
const reverted = error.walk((e) => e instanceof ContractFunctionRevertedError);
if (reverted instanceof ContractFunctionRevertedError && reverted.data) {
const { errorName, args = [] } = reverted.data;
return `${errorName}(${args.map(String).join(", ")})`; // WrongStatus(2, 1)
}
return error.shortMessage;
}
If errorName comes back undefined and the message says unrecognized custom error (return data: 0x…), the ABI is fine: the revert bytes never
reached viem, because the wallet or the endpoint dropped the data field.
Check the first four bytes against viem’s
toFunctionSelector('NotClient(address)'), or cast sig if you have
Foundry, and read the state through simulateContract, which does the eth_call from
your page and always carries the data.
Second, a confirmed receipt tells you the chain accepted your transaction.
It does not tell you that the node answering your next request has seen
it: RPC providers load-balance across nodes, and a read that follows a
write, a balance, a view call, a log query, can lag the receipt by a
block or two. Locally you will never see this; on Base Sepolia you will,
intermittently, and it looks exactly like a bug in your code. Retry reads
that follow a write for a few seconds before believing them, and never
assume that reading your own write is guaranteed.
Practical D: a frontend for SimpleEscrow
Fifty-five minutes. A starter is provided,
escrow-frontend.zip: a Vite React
project with wagmi configured for the local Hardhat network and Base
Sepolia, a wallet button, and an empty page. Unzip it next to your Hardhat
project, npm install, copy .env.example to .env.local with your
contract’s address, and run
npm run abi -- ../day-2/artifacts/contracts/SimpleEscrow.sol/SimpleEscrow.json
to give the page your ABI; its README has the details. Fill it in:
- Show the job: its status, client, freelancer, amount, and what the
connected address is owed. Read from the contract; refresh when a
transition event fires. One
useWatchContractEventwith noeventNamecatches every event of the contract with a single poller. One per event means one poller each, viem polls every four seconds, and nine events across four browser profiles at a demo is enough for a free-tier endpoint to answer 429, which your page will show as a revert. SetpollingIntervalon the wagmi config as well. - 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 five actions as buttons,
withdrawincluded, 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 failure needs a call your courtesy gating from step 2 would have prevented, so keep one button that sends the transaction regardless, and watch the contract refuse it.
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 in your repository. It also reads the process
environment, which is what CI will use on Day 4: the point of the keystore
is that your key is not in a file next to your code, not that environment
variables are forbidden. The keystore is one file per user account on the
machine, shared by every Hardhat project you have, so a value set in one
project replaces the same name in another; npx hardhat keystore path
shows where the production one lives, and keystore path --dev the
development one. Set the two values:
npx hardhat keystore set BASE_SEPOLIA_RPC_URL
npx hardhat keystore set BASE_SEPOLIA_PRIVATE_KEY
The first keystore set asks you to choose a password for the keystore
itself, and every later deploy and verify asks for it again. Write it
down somewhere you will still have at the final defence: it cannot be
recovered, and losing it means re-entering every value. keystore set --dev uses a separate development keystore with no password, which is
fine for this wallet; the project’s deployment key deserves the real one.
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
Answer y to the confirmation, and the keystore password when asked. 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.
Look at hardhat.config.ts before you deploy: it has two build profiles.
Tests use default, with the optimiser off, which compiles faster and
gives better stack traces. Ignition deploys with production, optimiser
on, which is what you ship, and which hardhat verify knows to use. So
every gas number you measure in a test is wrong for production, by nearly
half on deployment, and a Foundry fork test on Day 4 runs against the
optimised code while your unit tests run against the other. Run
npx hardhat test --build-profile production once before the freeze, so
that the suite you defend passes on the bytecode you deployed.
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
SimpleEscrow has no constructor arguments, which is the one case where
that command is enough. Your project’s contract will have some, and then
hardhat verify needs them too. Rather than retyping them, let Ignition
do it, since it recorded them at deployment:
npx hardhat ignition verify chain-84532 --network baseSepolia
The --network flag is required even though the deployment id names the
chain. This is the form you will actually use in the project.
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. The same command verified it on Blockscout and Sourcify as well; if Basescan’s bot check will not let you through, base-sepolia.blockscout.com shows the same thing.
Point the frontend at it. Two lines in the starter’s .env.local, the
chain id and the address; 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 within a week; the intermediate defence about three weeks in; the code freeze; the final defence. The dates are on the intranet.
- How the defences work, and in particular that every member answers for every part.
- Questions.
Groups form now. Three people, fourth and fifth year 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 and the attack are on the practicals page.
- 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, withdraw; inheritance,
interface, ABI, OpenZeppelin, Ownable, ReentrancyGuard; EIP-1193,
injected provider, public client, wallet client, receipt, pending,
confirmed, read after write; keystore, verification, Basescan; group, subject,
decentralisation test.