By the end of today you will have a development wallet, a local blockchain running on your laptop, a transaction you sent and read back through the raw RPC interface, and a smart contract you wrote, tested, and deployed. The morning builds the mental model; the afternoon builds the muscle memory. Both are deliberately hands-on — this is the day it helps most to have someone at the next desk.
| Time | Block |
|---|---|
| 10:00 | Kick-off: what this module is, what it is not |
| 10:15 | The trust problem |
| 10:35 | The mental model: a replicated state machine |
| 11:00 | Accounts, keys, addresses, signatures |
| 11:20 | Transactions and gas |
| 11:40 | Ethereum, the EVM, and why we are on Base |
| 11:55 | Practical A: wallet, node, first transactions |
| 13:00 | Lunch |
| 14:00 | A Hardhat project, inside and out |
| 14:20 | Solidity by doing: Counter |
| 15:00 | Reads, writes, and who is calling |
| 15:15 | Practical B: Guestbook, with tests and a deployment |
| 16:45 | Wrap-up, homework, and what Day 2 assumes |
Morning, 10:00 – 13:00
The trust problem
Every shared system has someone who runs it. Your bank keeps the ledger of who owns what; Steam keeps the ledger of who owns which game item; Google keeps the document you and your colleagues are editing. Most of the time that is fine, and the operator is competent and benign. The interesting cases are the ones where it is not: a payment processor that closes an account without explanation, a platform that changes its fee structure after you built a business on it, a game studio that shuts down the server your items lived on, a government that instructs a company to freeze a category of customer. In each case, the problem is not the technology. The problem is that a single party has the ability to act, and everyone else has to trust that it will not.
The traditional answer is law and reputation: the operator is constrained by contracts, regulation, and the cost of being caught. That works, mostly, within one jurisdiction, among parties who can afford lawyers. It works badly between strangers, across borders, at small amounts, or against the operator itself.
A blockchain is a different answer to the same question. Instead of constraining the operator, it removes the operator. The ledger is kept by everyone who cares to keep it, the rules are code that everyone can read, and the rules are applied by execution rather than by anyone’s decision. Nobody can freeze your account because nobody has that button. That is the entire pitch. Everything else — the cryptography, the consensus, the tokens — exists to make that pitch technically true.
It is worth saying immediately that removing the operator has a cost, and the cost is large. There is no one to call when something goes wrong. A mistake in the rules cannot be quietly patched. Every action is public. Everything is slower and more expensive than the version with an operator. This module spends as much time on when not to accept that cost as on how to accept it well.
The mental model: a replicated state machine
Strip away the vocabulary and a blockchain is this:
A state machine, copied onto thousands of computers, that advances only by applying signed transactions, in an order everyone agrees on.
Take each piece in turn.
A state machine. There is a state — for Ethereum, a mapping from addresses to accounts, each with a balance and, for some, code and storage. There is a transition function — given the current state and a transaction, produce the next state. That is all a blockchain computes.
Copied onto thousands of computers. Every node holds the entire state and every transaction that ever produced it. There is no primary. If your node disagrees with the rest, your node is wrong, by definition — the state is whatever the network agrees on. This is why “the blockchain went down” is not a sentence anyone says about Ethereum.
Advances only by signed transactions. Nothing changes the state except a transaction, and every transaction is signed by the key of the account it comes from. There is no administrative interface. There is no support ticket. The only way to move money out of an account is a signature from that account’s key, and the only way to change what a contract stores is a transaction that its code accepts.
In an order everyone agrees on. Transactions are batched into blocks, each block points to the previous one by hash, and the network agrees on which block comes next. How it agrees is consensus, and consensus is where most of the academic literature lives. For a developer, twenty minutes is enough: under Proof of Work, the right to produce the next block went to whoever burned the most electricity, which made rewriting history cost more than it could gain; under Proof of Stake, which Ethereum moved to in 2022, the right goes to validators who have locked up ETH, and a validator who signs conflicting histories loses it. Either way, the property you get is that once a block is deep enough — on Ethereum, “finalised”, roughly fifteen minutes — it will not be undone. Layer 2 networks like Base give you a softer confirmation in seconds and inherit Ethereum’s finality underneath.
The hash chain. Each block contains the hash of the block before it. Change one transaction in an old block and its hash changes, so the next block’s pointer no longer matches, and so on to the tip. Tampering is not prevented by the hash chain; it is made evident. Prevention is consensus’s job. Keep the two straight and half of the confused conversations about blockchains become clear.
What you should take from this: when you write a smart contract, you are writing the transition function for a small piece of that state. Your code runs on every node, identically, whenever anyone sends a transaction to it. That is what “decentralised” means, mechanically, and it explains every constraint you will hit this week — why loops are dangerous, why storage is expensive, why randomness is hard, why you cannot call an API.
Accounts, keys, addresses, signatures
There are two kinds of account, and the difference organises everything.
An externally owned account — an EOA, a “wallet” in everyday speech — is
a key pair. The private key is a 256-bit number you keep secret. The public
key is derived from it, and the address is the last twenty bytes of the
hash of the public key: 0x followed by forty hexadecimal characters. There
is no registration. Generate a key, and you have an address; the network
first hears of it when something is sent to it or from it.
A contract account has an address, a balance, code, and storage — and no private key. Nobody “owns” a contract in the cryptographic sense. It acts only when a transaction calls it, and it does only what its code says. Its address is determined at deployment, from the deployer’s address and nonce.
Signatures are how an EOA acts. To send a transaction, the wallet hashes
its contents and signs the hash with the private key. Any node can take the
signature and the hash and recover the public key — and therefore the
address — that produced it. That is how the network knows a transaction
came from you, without ever seeing your private key, and without a login.
Inside a contract, that recovered address is msg.sender, and you will
use it in almost every function you write.
The consequence people find hardest to internalise: there is no recovery. Lose the key and the account is gone — the funds are still there, visible to everyone, and nobody, including the network’s designers, can move them. Leak the key and the account is someone else’s. A wallet application like MetaMask holds keys for you, encrypted with a password, and gives you a twelve-word recovery phrase from which the keys are derived; the phrase is the keys. The security model of the entire system reduces to “keep that phrase secret”, which is a strong argument for the dedicated development wallet the prerequisites asked you to create.
Transactions and gas
A transaction is a small structure: the sender (implied by the signature),
a recipient address, an amount of ETH to transfer, a data field, a nonce,
and gas parameters. If the recipient is an EOA, the transaction is a
payment and data is usually empty. If the recipient is a contract, data
encodes which function to call and with what arguments, and the contract’s
code runs. If there is no recipient, the transaction creates a contract
from the code in data.
The nonce is a per-account counter: each transaction from an account carries the next number, which orders them and prevents a signed transaction from being replayed. Wallets manage it; you will notice it only when something is stuck.
Gas is the answer to two problems. The first is that a contract can loop forever, and a network where every node executes every transaction cannot afford one that never ends. The second is that execution costs something — every node does it — and someone has to pay. So every operation the EVM performs has a fixed price in units of gas, a transaction declares the most gas it will use, and it pays for what it consumes at a per-unit price in ETH. Run out of gas mid-way and the transaction fails — all its changes are reverted — but the gas is still paid, because the work was still done.
The price per unit is set by a market: since 2021 there is a base fee that rises when blocks are full and falls when they are not, plus a tip you can add to be included sooner. The base fee is burned; the tip goes to the validator. You will rarely set either by hand — the wallet estimates — but the shape matters for design: storage writes are among the most expensive operations, reads that do not change state are free when called from outside a transaction, and a function whose cost grows with the number of users will eventually cost more than a block can hold.
Ethereum, the EVM, and why we are on Base
Bitcoin, in 2009, showed that a decentralised ledger could work, for one application: moving one asset. Ethereum, in 2015, generalised it: instead of a ledger of balances, a ledger of programs. The Ethereum Virtual Machine is the execution environment those programs run in — a stack machine with a small instruction set, deterministic by construction, that every node implements identically. Solidity compiles to EVM bytecode; so do other languages, and so does the EVM itself onto other chains.
Ethereum won the platform race for a reason that will be familiar from every other platform: not because it was best, but because it was first to be programmable and open, so everything else was built on it, and every new thing built on it made it more valuable to build on. That is composability — a contract can call any other contract, and a new protocol can be assembled from existing ones without asking permission — and it is the property that makes the ecosystem more than a collection of apps.
Ethereum’s success created its problem: a single chain that every application shares is a chain where every application competes for the same block space, and by 2021 a simple transaction could cost tens of dollars. The answer the ecosystem settled on is Layer 2: networks that execute transactions off Ethereum, in bulk, and post the results back to Ethereum for security. A rollup runs its own EVM, batches thousands of transactions, and publishes them to Ethereum in a compressed form; Ethereum becomes the court of final appeal rather than the place where everything happens. Costs fall by two orders of magnitude; the developer experience is unchanged, because it is the same EVM.
Base is one such rollup — an optimistic rollup, built by Coinbase on Optimism’s stack, and one of the most used chains in the ecosystem. Its testnet, Base Sepolia, is where this module deploys. The choice is practical: same tooling and same Solidity as Ethereum, faster confirmations, and a testnet that is easier to get funded than Ethereum’s own. Everything you learn here transfers to Ethereum mainnet, to the other rollups, and to every other EVM chain by changing a URL in a config file.
Practical A: wallet, node, first transactions
An hour, at your own pace, with help in the room. The aim is to touch every layer once: a wallet holding keys, a node holding state, a transaction moving between them, and the raw interface underneath it all.
1. The wallet. Open MetaMask. You created a development wallet as a
prerequisite; if not, do it now. Find your address — the 0x… string —
and copy it. Paste it into Teams, in the module channel, with your name.
You will receive Base Sepolia ETH on it during the morning. Never paste the
recovery phrase anywhere, ever, including to the teacher.
2. The node. In the throwaway Hardhat project from the prerequisites
(or a fresh npx hardhat --init), run:
npx hardhat node
You now have an Ethereum node on http://127.0.0.1:8545, running its own
private chain, with twenty accounts pre-funded with 10,000 ETH each and
their private keys printed in the terminal. These keys are public
knowledge — they are the same for everyone who runs Hardhat — which is why
you must never send anything real to them.
3. Connect the wallet to the node. In MetaMask, add a network by hand:
name Hardhat, RPC URL http://127.0.0.1:8545, chain id 31337, currency
ETH. Then import one of the pre-funded accounts using its private key
from the terminal. Your wallet now shows a balance of 10,000 ETH on a chain
that exists only on your laptop.
4. Send a transaction. From the imported account, send 1 ETH to your development address. Watch the terminal running the node: it logs the transaction, the block it was mined in, the gas it used. Switch MetaMask to your development account and see the balance.
5. The raw interface. Everything the wallet just did went over JSON-RPC. Ask the node directly:
curl -s -X POST http://127.0.0.1:8545 \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}'
Then your balance — note the hex encoding, in wei, the smallest unit; one ETH is 10^18 wei:
curl -s -X POST http://127.0.0.1:8545 \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getBalance","params":["0xYOUR_ADDRESS","latest"]}'
Then the block your transaction was in, with eth_getBlockByNumber, and
the transaction itself, with eth_getTransactionByHash. Read the fields.
Every one of them was described in the last hour. This is the whole
interface: a wallet, a library, an explorer, a dApp — every one of them is
a client making these calls to a node.
6. The public network. By now your development address has Base Sepolia
ETH. Switch MetaMask to Base Sepolia (it may need adding: chain id 84532,
RPC https://sepolia.base.org, explorer https://sepolia.basescan.org).
Send a small amount — 0.001 ETH — to a neighbour’s address. Then open the
transaction on Basescan. Find: the block, the nonce, the gas used, the gas
price, the fee paid, the input data (empty), and the status. Compare with
what your local node showed. It is the same machine, with more computers
running it.
Afternoon, 14:00 – 17:00
A Hardhat project, inside and out
Hardhat is the development environment: it compiles Solidity, runs a local node, runs tests, and deploys. Create a project for the afternoon:
mkdir day-1 && cd day-1
npx hardhat --init
Choose a TypeScript project with viem. Look at what it made:
hardhat.config.ts— the compiler version, the networks the project knows about, and where to find things.contracts/— Solidity source. Every.solfile here is compiled.test/— tests, in TypeScript, using Node’s built-in test runner and viem to talk to the contracts.ignition/modules/— deployment modules. Hardhat Ignition is the deployment system: you describe what to deploy and it works out the transactions, and remembers what it already deployed per network.artifacts/andcache/— compiler output, generated, never committed.
The example project has a contract, a test, and a module already. Run
npx hardhat test to see it work, then delete the example contract and
its test — you are about to write your own.
Solidity by doing: Counter
The smallest contract that does something:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
contract Counter {
uint256 public count;
event Incremented(address indexed by, uint256 newCount);
function increment() external {
count += 1;
emit Incremented(msg.sender, count);
}
}
Line by line, because every line carries a concept.
pragma solidity ^0.8.28; pins the compiler. Solidity moves fast and old
versions had real footguns — before 0.8, integer overflow silently wrapped
around, and a class of hacks lived there. Everything in this module is 0.8.
contract Counter { … } is the unit of deployment. One contract, one
address, one bytecode, one storage.
uint256 public count; is a state variable: it lives in the contract’s
storage, on chain, persistently. uint256 is the native word size — a
256-bit unsigned integer, which is why you will see it everywhere, and why
there are no floats. public makes the compiler generate a getter, so
anyone can read count() without a transaction.
event Incremented(…) declares a log. Events are not readable by
contracts; they are for the outside world. A frontend or an indexer
subscribes to them. indexed makes a parameter filterable — “show me every
Incremented by this address”. Events are cheap compared to storage, and
they are the read interface of every well-designed contract.
function increment() external is a function anyone can call.
external means callable only from outside — from a transaction or another
contract — which is what you want for an entry point. It has no view, so
it changes state, so calling it costs a transaction.
count += 1; is a storage write, and the most expensive thing in this
contract.
msg.sender is the address that called this function — recovered from the
transaction’s signature if called directly, or the calling contract’s
address if called from a contract. It is how a contract knows who is acting,
and it cannot be faked.
Put it in contracts/Counter.sol and compile:
npx hardhat compile
Now a test, in test/Counter.ts:
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { network } from "hardhat";
describe("Counter", async function () {
const { viem } = await network.connect();
it("starts at zero", async function () {
const counter = await viem.deployContract("Counter");
assert.equal(await counter.read.count(), 0n);
});
it("increments and emits", async function () {
const counter = await viem.deployContract("Counter");
const [wallet] = await viem.getWalletClients();
await counter.write.increment();
assert.equal(await counter.read.count(), 1n);
const events = await counter.getEvents.Incremented();
assert.equal(events.length, 1);
assert.equal(events[0].args.by, wallet.account.address);
assert.equal(events[0].args.newCount, 1n);
});
});
network.connect() gives you a connection to an in-process Hardhat
network, fresh for the test file, with the same pre-funded accounts as the
node you ran this morning. viem.deployContract compiles, deploys, and
hands back a typed contract object: read for views, write for
transactions, getEvents for logs. Note 0n and 1n — uint256 does not
fit in a JavaScript number, so viem gives you bigint, and it will save
you from a class of subtle bugs.
npx hardhat test
Then deploy it to a running node. In one terminal, npx hardhat node. In
ignition/modules/Counter.ts:
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
export default buildModule("CounterModule", (m) => {
const counter = m.contract("Counter");
return { counter };
});
And in another terminal:
npx hardhat ignition deploy ignition/modules/Counter.ts --network localhost
Ignition prints the address. Add that address to nothing yet — just notice that the node’s terminal shows a contract-creation transaction, that the address is derived from the deployer and its nonce, and that deploying again to the same network does nothing, because Ignition remembers.
Reads, writes, and who is calling
Three ideas from the last hour, made explicit, because the whole afternoon hinges on them.
A read is free; a write is a transaction. Calling count() asks a node
for a value from its copy of the state. No transaction, no gas, no
signature, instant. Calling increment() asks the network to change the
state: it needs a signed transaction, it pays gas, it takes a block to
confirm, and it might fail. Every contract function is one or the other,
and the compiler makes you say which — view (and pure) for reads,
nothing for writes. Design follows from this: put as much as possible in
reads, and make writes small.
Storage is the expensive thing. Writing a 32-byte slot for the first
time costs about 20,000 gas; the whole increment() transaction is
dominated by it. Reading is cheaper, events are cheaper still, and doing
arithmetic is nearly free. A contract that stores what it could compute,
or stores what only a frontend needs, is paying rent on every user’s
behalf forever.
msg.sender is your authentication. There is no session, no login, no
user table. The address that signed the transaction is who is acting, and
the contract decides what that address may do. Every access rule you will
ever write is some comparison against msg.sender.
Practical B: Guestbook, with tests and a deployment
Ninety minutes. Write a contract from a specification, test it, deploy it to your local node, and interact with it from a script. The specification:
A
Guestbooklets anyone leave a message. Each entry records the sender’s address, the message text, and the block timestamp. Anyone can read how many entries there are and read any entry by index. Leaving a message emits an event. A message may not be empty. A given address may leave at most one message per day.
Things you will need that Counter did not show, in the order you will
meet them:
- A
structto hold an entry, and an array of them:Entry[] public entries;. Solidity generates a getter that takes an index. string calldata messageas a parameter —calldatabecause it is read-only input and the cheapest place for it to live.block.timestamp, the current block’s time in seconds, set by the block producer. Good enough for “once per day”; not good enough for anything that needs to be precise or unmanipulable, which is a Day 3 topic.require(condition, "reason")to reject a transaction, which reverts every change it made and returns the reason to the caller. Or, better and cheaper, a custom error:error EmptyMessage();andif (bytes(message).length == 0) revert EmptyMessage();.- A
mapping(address => uint256) public lastPosted;to remember when each address last wrote. A mapping is a hash table with no length and no iteration — every key exists and maps to zero until written.
Your tests should cover: the count is zero at deployment; a message is stored with the right sender and text; the event is emitted; an empty message reverts; a second message within a day reverts; a second message after a day succeeds. For the last one, Hardhat lets you move time:
const { viem, networkHelpers } = await network.connect();
await networkHelpers.time.increase(24 * 60 * 60);
For reverts, viem throws; assert on the rejection:
await assert.rejects(
guestbook.write.post([""]),
/EmptyMessage/,
);
Then an Ignition module, a deployment to localhost, and a short script —
scripts/post.ts — that connects to the node, posts a message from one of
the pre-funded accounts, and prints the entry back. Run it with
npx hardhat run scripts/post.ts --network localhost. When it works,
import the deployer’s account into MetaMask, and see that the guestbook
transaction shows up in its activity.
If you finish early: add a getEntries(uint256 from, uint256 count) view
that returns a slice, and think about why returning the whole array is a
bad idea at ten thousand entries. Then think about whether the array
should exist at all, given that the events already contain everything.
Homework
Finish Guestbook if you did not, tests included. The reference solution
will be published after Day 2. Then, before tomorrow:
- Re-read the prerequisites and make sure you have an RPC provider URL for Base Sepolia and an Etherscan API key. Tomorrow ends with a deployment to the public testnet, and account sign-ups are the slowest part.
- Spend twenty minutes on Basescan looking at a real contract. Search for
USDCon Base Sepolia, open the contract, find the “Read Contract” and “Write Contract” tabs, and the verified source. You will understand about a third of it. That is the point — tomorrow it will be two thirds.
Vocabulary from today
State, transaction, block, node, consensus, finality; EOA, contract
account, private key, address, signature, recovery phrase; nonce, gas,
base fee, tip, revert; EVM, bytecode, Layer 2, rollup, Base, testnet;
JSON-RPC, wallet, explorer; state variable, storage, event, msg.sender,
view, external, require, custom error, mapping, struct.
If any of these would not survive a question from a colleague, the notes above are where to look.