blockchain.lucas.zip

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

Seminar, 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 6 September 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 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 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 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: release and cancel record what an address is owed, and withdraw pays 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.

SimpleEscrow as a state machine. Draw yours before writing a line of Solidity.

SimpleEscrow as a state machine. Draw yours before writing a line of Solidity.

Requirements for the test suite, which is the real deliverable of this practical:

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.

Four hops. Writes go through the wallet, which signs; reads go straight to a node.

Four hops. Writes go through the wallet, which signs; reads go straight to a node.

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.

The lifecycle every button in a dApp has to handle.

The lifecycle every button in a dApp has to handle.

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:

  1. 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 useWatchContractEvent with no eventName catches 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. Set pollingInterval on the wagmi config as well.
  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 five actions as buttons, withdraw included, 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 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:

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

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.