blockchain.lucas.zip

Day 1 — The machine, and your first contract

Thursday 15 October, on site. What a blockchain is to a developer; a wallet, a node, a transaction; then Solidity, Hardhat, and a deployed contract by five o'clock. · Updated 26 August 2026

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.

TimeBlock
10:00Kick-off: what this module is, what it is not
10:15The trust problem
10:35The mental model: a replicated state machine
11:00Accounts, keys, addresses, signatures
11:20Transactions and gas
11:40Ethereum, the EVM, and why we are on Base
11:55Practical A: wallet, node, first transactions
13:00Lunch
14:00A Hardhat project, inside and out
14:20Solidity by doing: Counter
15:00Reads, writes, and who is calling
15:15Practical B: Guestbook, with tests and a deployment
16:45Wrap-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:

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 1nuint256 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 Guestbook lets 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:

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:

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.