blockchain.lucas.zip

Day 2 — Solidity for real, a frontend, and the public network

Friday 16 October, on site. The language in depth; a dApp in the browser with viem and wagmi; a deployment to Base Sepolia, verified; and the project briefing. · Updated 26 August 2026

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.

TimeBlock
10:00Solidity for real: types, storage, and the cost of things
10:35Rules and failures: require, custom errors, modifiers
10:50Money: payable, sending ETH, and the first security lesson
11:15Reuse: inheritance, interfaces, OpenZeppelin
11:30Practical C: SimpleEscrow, from specification, with tests
13:00Lunch
14:00How a dApp is wired: browser, wallet, provider, node
14:20viem and wagmi, by example
14:50Practical D: a frontend for SimpleEscrow
15:45Practical E: deploy to Base Sepolia and verify on Basescan
16:15Project briefing
17:00End — 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 SimpleEscrow holds 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:

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.

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:

  1. Show the job: its status, client, freelancer, amount. Read from the contract, refresh when the Status event fires.
  2. 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.
  3. The four actions as buttons, each following the lifecycle above: click, pending, confirmed or reverted with the decoded reason.
  4. 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:

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

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.