Interactive playground · Course structure · Worked example project
Build & deploy a token + crowdfunding dApp
This page is a single, in-depth worked example that stitches the whole back half of the course into one shippable project. You will write a real ERC-20 token in Solidity, a crowdfunding / escrow contract that holds contributors' funds and releases them only when a goal is met (refunding everyone otherwise), a small ethers.js front-end that connects a wallet and reads/writes the contracts, and then deploy to a public testnet and analyse the result for gas cost and security. Every line of Solidity and JavaScript below is real and compiles on solc 0.8.x / ethers v6; the explanations call out the mechanics — hashing, gas, the ERC-20 interface, and the re-entrancy class of bug — as we go.
What you'll build, section by section
The syllabus asks you to "program and simulate custom programs (i.e. smart contracts) for currency" and to "build a DAO, deploy it on a public blockchain, and interface with it through a Dapp". A crowdfunding escrow plus its reward token is the smallest project that genuinely exercises all of that: a fungible currency (the token), conditional value transfer with custody (the escrow), the safety patterns that separate toy code from deployable code, and a browser front-end that talks to a live chain. It is also a direct on-ramp to the DAO material in Sessions 8–9 — a DAO is essentially this contract with voting bolted on.
1 · Architecture
The system is two contracts and one web page. The Crowdfund contract is the heart of it: contributors send ETH while a campaign is open; if the goal is reached by the deadline the beneficiary can withdraw the pot, and every contributor is minted RewardToken proportional to what they put in; if the goal is missed, contributors pull their ETH back themselves. The front-end never holds keys or funds — it just encodes calls and lets the user's wallet sign them.
┌─────────────────────── Browser (dApp) ───────────────────────┐
│ index/project page + ethers.js v6 + MetaMask (EIP-1193) │
└───────────────┬───────────────────────────────┬──────────────┘
│ read (eth_call, free) │ write (eth_sendTransaction, costs gas)
▼ ▼
┌───────────────────────────────┐ signs & broadcasts tx
│ Sepolia testnet node (RPC) │◀───────────────────────────────
└───────────────┬───────────────┘
│ EVM execution
┌───────────────▼───────────────────────────────────────────────┐
│ Crowdfund (escrow) │
│ state: goal, deadline, beneficiary, totalRaised, │
│ contributions[addr], claimed, token │
│ contribute() payable ── records ETH, emits Contributed │
│ withdraw() ───────── goal met → pays beneficiary │
│ refund() ───────── goal missed → returns contributor ETH│
│ claimReward() ──────── mints RewardToken to contributor │
└───────────────┬───────────────────────────────────────────────┘
│ calls mint()
┌───────────────▼───────────────────────────────────────────────┐
│ RewardToken (ERC-20) │
│ balanceOf / transfer / approve / transferFrom / allowance │
│ mint() (only the Crowdfund, set as minter) │
└────────────────────────────────────────────────────────────────┘Events the contracts emit
Events are the contract's append-only log. They cost far less gas than storage, are not readable by other contracts, and are exactly what a front-end or an indexer (The Graph, a block explorer) subscribes to in order to react to on-chain activity in real time.
| Event | Emitted by | Why the dApp cares |
|---|---|---|
| Contributed | contribute() | Update the progress bar and the contributor's running total. |
| Withdrawn | withdraw() | Mark the campaign as successfully closed; hide the contribute button. |
| Refunded | refund() | Confirm a contributor got their ETH back after a failed campaign. |
| Transfer | ERC-20 transfer/mint | The standard token event — wallets and explorers track balances from it. |
Keeping the token and the escrow in separate contracts is deliberate. The token is a reusable, standard-conformant asset that any wallet or exchange understands; the escrow is application logic that uses the token. This is the same modularity that lets the same USDC contract back hundreds of unrelated DeFi apps.
2 · The ERC-20 token contract
ERC-20 is an interface: a set of six functions and two events that every
fungible token must expose so that wallets, exchanges and other contracts can treat all tokens uniformly.
The two non-obvious ones — approve and transferFrom — implement a
delegated allowance: you grant a contract permission to pull up to N tokens
from you, and it later pulls them with transferFrom. That two-step "approve then pull" is
what every DEX and lending protocol relies on.
totalSupply(), balanceOf(addr), transfer(to, amt),
approve(spender, amt), allowance(owner, spender),
transferFrom(from, to, amt) — plus the Transfer and Approval
events. Balances are just a mapping(address => uint256); "sending" a token is
decrementing one entry and incrementing another. Nothing leaves the contract.
Here is a complete, self-contained implementation. It is written without external
libraries so you can read every line, but it follows the same logic as OpenZeppelin's audited version
(which you would use in production — see the pitfall below). Note unchecked blocks: under
Solidity 0.8 every arithmetic operation reverts on overflow by default, so we only drop that guard where
a preceding require has already proven it is safe, saving gas.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title RewardToken — a minimal, safe ERC-20 with controlled minting.
/// @notice Balances live in a mapping; "transferring" is just editing two entries.
contract RewardToken {
string public constant name = "Campaign Reward";
string public constant symbol = "CRS";
uint8 public constant decimals = 18; // 1 token = 1e18 base units
uint256 public totalSupply;
address public minter; // the Crowdfund contract
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
error NotMinter();
error InsufficientBalance();
error InsufficientAllowance();
constructor(address minter_) {
minter = minter_; // only this address may mint
}
/// @notice Mint new tokens. Restricted to the campaign contract.
function mint(address to, uint256 amount) external {
if (msg.sender != minter) revert NotMinter();
totalSupply += amount; // 0.8.x reverts on overflow
unchecked { balanceOf[to] += amount; } // safe: bounded by totalSupply
emit Transfer(address(0), to, amount); // mint = transfer from the zero address
}
/// @notice Move `amount` of your own tokens to `to`.
function transfer(address to, uint256 amount) external returns (bool) {
return _transfer(msg.sender, to, amount);
}
/// @notice Approve `spender` to pull up to `amount` of your tokens.
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
/// @notice Pull `amount` from `from` to `to`, spending the caller's allowance.
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
uint256 allowed = allowance[from][msg.sender];
if (allowed < amount) revert InsufficientAllowance();
if (allowed != type(uint256).max) { // infinite approval = no decrement
allowance[from][msg.sender] = allowed - amount;
}
return _transfer(from, to, amount);
}
function _transfer(address from, address to, uint256 amount) internal returns (bool) {
uint256 bal = balanceOf[from];
if (bal < amount) revert InsufficientBalance();
unchecked {
balanceOf[from] = bal - amount; // safe: checked bal >= amount above
balanceOf[to] += amount; // safe: total supply is the ceiling
}
emit Transfer(from, to, amount);
return true;
}
}
Walkthrough
- State is two mappings.
balanceOftracks who owns what; the nestedallowancemapping tracks "owner → spender → how much the spender may pull". Reads of public mappings are free (they generate auto getters); writes cost gas because they touch storage. - Minting is a transfer from address(0). By convention, creating tokens emits
Transfer(address(0), to, amount). Block explorers and wallets recognise that pattern as a mint and credit the balance accordingly — you get correct UI for free by following the standard. - Access control on
mint. Only the storedminter(our Crowdfund contract) may create tokens. Everyone else reverts withNotMinter(). This is the simplest form of role-based access control; OpenZeppelin'sOwnable/AccessControlgeneralise it. - Allowance + transferFrom. A spender (e.g. a DEX) calls
transferFromand we decrement their allowance unless it was set to the sentinel "infinite" value. This avoids one storage write per pull for tokens that are approved once for an unlimited amount. - Custom errors over revert strings.
revert NotMinter()is cheaper thanrequire(..., "not minter")because the four-byte selector replaces a string in calldata — a small but standard 0.8.x gas optimisation.
This contract is written by hand so you can see the mechanics, and it is correct — but in
production you should inherit OpenZeppelin's audited ERC20 and Ownable. Subtle
bugs (the classic approve race condition, missing zero-address checks, decimals
assumptions) have drained real money from naive re-implementations. Read the standard, then stand on
the shoulders of audited code.
3 · The crowdfunding / escrow contract
This is the application. It holds ETH in escrow while a campaign runs, then branches on the outcome: goal met → the beneficiary withdraws and contributors can claim reward tokens; goal missed → contributors pull their ETH back. The interesting engineering is not the happy path — it's making the money-moving functions safe against re-entrancy and impossible to call out of order. We use modifiers for the ordering rules and the checks-effects-interactions pattern for the value transfers.
Order every state-changing function as: (1) checks — validate inputs and require conditions; (2) effects — update this contract's storage; (3) interactions — only now call out to other addresses (send ETH, call another contract). Because storage is already settled before the external call, a malicious callee that re-enters finds nothing left to drain. CEI is the single most important Solidity safety habit.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IRewardToken {
function mint(address to, uint256 amount) external;
}
/// @title Crowdfund — a goal-or-refund escrow that rewards contributors with a token.
contract Crowdfund {
address public immutable beneficiary; // who receives funds if the goal is met
uint256 public immutable goal; // target in wei
uint256 public immutable deadline; // unix timestamp
IRewardToken public immutable token; // reward token (this contract is its minter)
uint256 public totalRaised;
bool public withdrawn; // beneficiary has been paid
mapping(address => uint256) public contributions;
mapping(address => bool) public rewardClaimed;
// --- re-entrancy guard (the OpenZeppelin nonReentrant pattern) ---
uint256 private _locked = 1; // 1 = unlocked, 2 = locked
modifier nonReentrant() {
require(_locked == 1, "reentrancy");
_locked = 2;
_;
_locked = 1;
}
event Contributed(address indexed who, uint256 amount, uint256 newTotal);
event Withdrawn(address indexed beneficiary, uint256 amount);
event Refunded(address indexed who, uint256 amount);
event RewardClaimed(address indexed who, uint256 tokens);
modifier campaignOpen() {
require(block.timestamp < deadline, "campaign ended");
_;
}
modifier campaignEnded() {
require(block.timestamp >= deadline, "campaign still open");
_;
}
constructor(address beneficiary_, uint256 goalWei, uint256 durationSecs, address token_) {
require(beneficiary_ != address(0), "zero beneficiary");
require(goalWei > 0, "zero goal");
beneficiary = beneficiary_;
goal = goalWei;
deadline = block.timestamp + durationSecs;
token = IRewardToken(token_);
}
/// @notice Contribute ETH while the campaign is open.
function contribute() external payable campaignOpen {
require(msg.value > 0, "no value");
contributions[msg.sender] += msg.value; // effect
totalRaised += msg.value; // effect
emit Contributed(msg.sender, msg.value, totalRaised);
}
/// @notice Beneficiary withdraws once the goal is met (after the deadline).
function withdraw() external campaignEnded nonReentrant {
require(msg.sender == beneficiary, "not beneficiary"); // check
require(totalRaised >= goal, "goal not met"); // check
require(!withdrawn, "already withdrawn"); // check
withdrawn = true; // effect (before the call!)
uint256 amount = address(this).balance;
(bool ok, ) = beneficiary.call{value: amount}(""); // interaction
require(ok, "transfer failed");
emit Withdrawn(beneficiary, amount);
}
/// @notice Contributors pull their ETH back if the goal was missed.
function refund() external campaignEnded nonReentrant {
require(totalRaised < goal, "goal was met"); // check
uint256 amount = contributions[msg.sender]; // check
require(amount > 0, "nothing to refund"); // check
contributions[msg.sender] = 0; // EFFECT before interaction
(bool ok, ) = msg.sender.call{value: amount}(""); // interaction
require(ok, "refund failed");
emit Refunded(msg.sender, amount);
}
/// @notice Successful contributors claim reward tokens 1:1 with wei contributed.
function claimReward() external campaignEnded {
require(totalRaised >= goal, "campaign failed"); // check
require(!rewardClaimed[msg.sender], "already claimed"); // check
uint256 amount = contributions[msg.sender]; // check
require(amount > 0, "not a contributor"); // check
rewardClaimed[msg.sender] = true; // effect
token.mint(msg.sender, amount); // interaction (trusted token)
emit RewardClaimed(msg.sender, amount);
}
}
Function-by-function
| Function | Guards | What it does |
|---|---|---|
| contribute | campaignOpen, payable | Records the sent ETH against the sender and the running total, then logs it. State-only — no external call, so no re-entrancy surface. |
| withdraw | campaignEnded, nonReentrant | Beneficiary-only; requires the goal met; flips withdrawn before sending, then transfers the whole balance via low-level call. |
| refund | campaignEnded, nonReentrant | Only when the goal was missed; zeroes the contributor's record before sending ETH back — textbook CEI. |
| claimReward | campaignEnded | On success, mints reward tokens 1:1 with wei contributed; the rewardClaimed flag prevents double-claims. |
Notice we never loop over contributors to push refunds — that would run out of gas with many contributors and let one reverting recipient block everyone. Instead each contributor pulls their own refund. "Favor pull over push" is a core smart-contract design rule.
block.timestamp is loosely trusted
Validators can nudge block.timestamp by a few seconds. For a multi-day crowdfunding
deadline that is harmless, but never use it as a randomness source or for sub-minute timing-critical
logic. Here the deadline tolerance is far larger than any manipulation, so it's fine.
4 · The dApp front-end (ethers.js)
A dApp front-end is an ordinary web page that talks to the chain through the
user's wallet. It does two kinds of work: reads (call view functions —
free, no signature) and writes (send transactions — the wallet pops up, the user signs,
gas is paid). With ethers.js v6 you wrap the injected window.ethereum provider in a
BrowserProvider, get a Signer for writes, and instantiate a
Contract from its ABI and address.
A provider is a read-only connection to the chain (it can call view
functions and read logs). A signer is a provider plus the ability to authorise
transactions with a private key — in the browser that key lives in MetaMask, never in your page.
Reads use the provider; writes use the signer.
import { ethers } from "https://cdn.jsdelivr.net/npm/ethers@6.13.0/+esm";
// Minimal ABI — only the members the dApp actually uses.
const CROWDFUND_ABI = [
"function goal() view returns (uint256)",
"function deadline() view returns (uint256)",
"function totalRaised() view returns (uint256)",
"function contributions(address) view returns (uint256)",
"function withdrawn() view returns (bool)",
"function contribute() payable",
"function withdraw()",
"function refund()",
"function claimReward()",
"event Contributed(address indexed who, uint256 amount, uint256 newTotal)"
];
const CROWDFUND_ADDRESS = "0xYourDeployedCrowdfundAddressHere";
let provider, signer, contract;
// 1) Connect the wallet (EIP-1193 handshake).
async function connect() {
if (!window.ethereum) { alert("Install MetaMask"); return; }
provider = new ethers.BrowserProvider(window.ethereum);
await provider.send("eth_requestAccounts", []); // prompts the wallet
signer = await provider.getSigner();
contract = new ethers.Contract(CROWDFUND_ADDRESS, CROWDFUND_ABI, signer);
document.getElementById("addr").textContent = await signer.getAddress();
await refresh();
subscribe();
}
// 2) READ — view calls are free and need no signature.
async function refresh() {
const [goal, raised, deadline, withdrawn] = await Promise.all([
contract.goal(),
contract.totalRaised(),
contract.deadline(),
contract.withdrawn()
]);
const pct = goal === 0n ? 0 : Number((raised * 100n) / goal);
document.getElementById("raised").textContent =
`${ethers.formatEther(raised)} / ${ethers.formatEther(goal)} ETH (${pct}%)`;
const ended = BigInt(Math.floor(Date.now() / 1000)) >= deadline;
document.getElementById("status").textContent =
withdrawn ? "funded & withdrawn" : ended ? "ended" : "open";
}
// 3) WRITE — send a transaction; the wallet signs, we pay gas, then wait for mining.
async function contribute(ethAmount) {
const tx = await contract.contribute({ value: ethers.parseEther(ethAmount) });
document.getElementById("status").textContent = "pending: " + tx.hash;
const receipt = await tx.wait(); // 1 confirmation
console.log("mined in block", receipt.blockNumber, "gas used", receipt.gasUsed);
await refresh();
}
async function refund() { await (await contract.refund()).wait(); refresh(); }
async function claimReward() { await (await contract.claimReward()).wait(); refresh(); }
// 4) React to on-chain events in real time (this is why we emit them).
function subscribe() {
contract.on("Contributed", (who, amount, newTotal) => {
console.log(`${who} added ${ethers.formatEther(amount)} ETH`);
refresh();
});
}
document.getElementById("connect").addEventListener("click", connect);
document.getElementById("give").addEventListener("click", () =>
contribute(document.getElementById("amount").value)
);
What's happening
- The ABI is the contract's API. ethers only needs the signatures of the functions you call — a human-readable string array is enough. The compiler also emits a full JSON ABI you can import instead.
- Reads via
Promise.all. View calls hit the node'seth_call, return instantly and cost no gas. We batch four of them in parallel. - Writes return a tx, then you
await tx.wait(). The first await resolves when the wallet broadcasts (you have a hash); the second resolves when it's mined (you have a receipt withgasUsed). BigInt math (0n,100n) is mandatory — token amounts overflow JS numbers. parseEther/formatEther. The chain works in wei (1 ETH = 10¹⁸ wei). These helpers convert to and from human units so you never hand-count eighteen zeros.- Event subscription.
contract.on("Contributed", ...)is why the contract emits events: the UI updates the moment anyone contributes, without polling.
The page can hide a button, but anyone can call your contract directly with the same ABI. All
access control and validation must live in Solidity (the requires and modifiers above).
The front-end is convenience, not a security boundary.
5 · Deploy & test
You can deploy either in the browser with Remix (zero setup, great for a first deploy) or from the command line with Hardhat (scriptable, testable, what you'd use for the real exercise). Both target the Sepolia testnet, where ETH is free from a faucet — never deploy unfinished code to mainnet.
Path A · Remix (fastest)
- Open
remix.ethereum.organd paste both.solfiles. - Compile with solc 0.8.24 (Solidity Compiler tab → Compile).
- Deploy the token first. In the Deploy tab choose "Injected Provider — MetaMask", select
Sepolia, and deploy
RewardTokenwith the future Crowdfund address — or deploy the Crowdfund first and call a one-timesetMinter(see ordering note below). - Deploy
Crowdfundpassing(beneficiary, goalWei, durationSecs, tokenAddr). - Interact from Remix's auto-generated UI or your dApp page — call
contributewith a "Value" in wei, watch the events in the terminal.
The token's minter must be the Crowdfund, but the Crowdfund's constructor needs the
token address — a circular dependency. The clean fix is to make the token's minter settable once:
deploy the token with minter = deployer, deploy the Crowdfund with the token address,
then call token.setMinter(crowdfundAddr) and renounce. (Add a guarded
setMinter for this; it's a two-line change to the listing above.)
Path B · Hardhat (scriptable + tested)
const { ethers } = require("hardhat");
async function main() {
const [deployer] = await ethers.getSigners();
// 1) Deploy the token with the deployer as temporary minter.
const Token = await ethers.getContractFactory("RewardToken");
const token = await Token.deploy(deployer.address);
await token.waitForDeployment();
// 2) Deploy the campaign: goal 10 ETH, 7-day window.
const goal = ethers.parseEther("10");
const duration = 7 * 24 * 60 * 60;
const Crowdfund = await ethers.getContractFactory("Crowdfund");
const cf = await Crowdfund.deploy(
deployer.address, goal, duration, await token.getAddress()
);
await cf.waitForDeployment();
// 3) Hand minting rights to the campaign.
await (await token.setMinter(await cf.getAddress())).wait();
console.log("Token: ", await token.getAddress());
console.log("Crowdfund:", await cf.getAddress());
}
main().catch((e) => { console.error(e); process.exit(1); });
Run it against Sepolia with an RPC URL and a funded test key in your
hardhat.config.js:
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat test # run the unit tests below first
npx hardhat run scripts/deploy.js --network sepolia
npx hardhat verify --network sepolia <address> <constructor args>
A test is where you prove the safety properties actually hold. The key one for this project: a failed campaign must let contributors recover their ETH and must block withdrawal.
const { expect } = require("chai");
const { ethers } = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-network-helpers");
describe("Crowdfund", () => {
it("refunds contributors when the goal is missed", async () => {
const [owner, alice] = await ethers.getSigners();
const Token = await ethers.getContractFactory("RewardToken");
const token = await Token.deploy(owner.address);
const Crowdfund = await ethers.getContractFactory("Crowdfund");
const cf = await Crowdfund.deploy(
owner.address, ethers.parseEther("10"), 3600, await token.getAddress()
);
await cf.connect(alice).contribute({ value: ethers.parseEther("1") });
await time.increase(3601); // jump past the deadline
await expect(cf.withdraw()).to.be.revertedWith("goal not met");
await expect(cf.connect(alice).refund())
.to.emit(cf, "Refunded")
.withArgs(alice.address, ethers.parseEther("1"));
});
});
6 · Gas & security analysis
Two questions decide whether a contract is production-ready: what does it cost to use? and can it be drained? This section profiles the gas of each function and walks through the security properties — re-entrancy, the checks-effects-interactions ordering, and integer overflow — that the code above is built to satisfy.
Gas profile
Gas is the EVM's unit of computational work; the fee you pay is
gasUsed × gasPrice. Storage writes dominate: a fresh SSTORE (zero → non-zero)
is ~20,000 gas, an update ~5,000, and refunds are given for clearing storage. The figures below are
representative for this contract on the EVM.
| Operation | ~Gas | Why |
|---|---|---|
| deploy RewardToken | ~700,000 | One-off: stores the bytecode on-chain. |
| deploy Crowdfund | ~900,000 | One-off: larger bytecode + immutables in code. |
| contribute (first) | ~70,000 | Two fresh SSTOREs (contribution + total) + event. |
| contribute (repeat) | ~30,000 | Both slots already non-zero → cheaper updates. |
| transfer (ERC-20) | ~51,000 | Decrement one balance, increment another, emit event. |
| refund / withdraw | ~40,000 | One storage write + the ETH-sending call. |
| claimReward | ~75,000 | Flag write + a fresh balance SSTORE in the token. |
Gas-saving choices already in the code: immutable for goal/deadline/beneficiary
(read from bytecode, not storage), custom errors instead of revert strings, and unchecked
blocks where overflow is already proven impossible.
Security · re-entrancy
When a contract sends ETH with .call, the recipient can be another contract
whose receive() function runs arbitrary code — including calling back into your
function before it finished. If you sent the ETH before zeroing the contributor's balance,
the attacker re-enters refund() and is refunded again and again. This is the 2016 DAO
hack that cost ~$60M and forced the Ethereum/Classic split.
contract Attacker {
Crowdfund public target;
constructor(address t) { target = Crowdfund(t); }
function attack() external payable {
target.contribute{value: msg.value}();
target.refund(); // triggers receive() below
}
// The EVM calls this on incoming ETH — the re-entrancy hook.
receive() external payable {
if (address(target).balance >= msg.value) {
target.refund(); // re-enter BEFORE the first call returns
}
}
}
Our refund() defeats this in two independent ways:
- Checks-effects-interactions. We set
contributions[msg.sender] = 0before thecall. When the attacker re-enters, therequire(amount > 0)check now fails — there is nothing left to refund. CEI alone is sufficient here. - The
nonReentrantguard. Belt and braces: the lock flag is set on entry, so any re-entrant call reverts with"reentrancy"regardless of state ordering. This protects even if a future edit accidentally violates CEI.
Security · integer overflow
Before Solidity 0.8, uint256 wrapped silently — 2²⁵⁶−1 + 1 == 0 — and
attackers exploited it to forge enormous balances (the 2018 "batchOverflow" bug). Since 0.8 the
compiler inserts overflow/underflow checks on every + − × and reverts on violation. We
only drop into unchecked where a preceding require already guarantees safety,
trading a tiny gas saving for no loss of correctness.
Security checklist
| Risk | Mitigation in this code |
|---|---|
| Re-entrancy | CEI ordering + nonReentrant modifier on every ETH-sending function. |
| Integer overflow | Solidity 0.8 checked math; unchecked only where proven safe. |
| Unauthorized mint | mint reverts unless msg.sender == minter. |
| Double withdraw / claim | withdrawn and rewardClaimed flags set before the action. |
| Locked funds | Pull-based refunds — no loop that can be DoS'd by one reverting recipient. |
| Calling out of order | campaignOpen / campaignEnded modifiers gate by deadline. |
| Failed ETH send | Low-level call return value checked with require(ok). |
Secure Solidity is mostly discipline, not cleverness: settle your own state before you talk to anyone else (CEI), guard re-entry, check every external call's result, and let the 0.8 compiler catch your arithmetic. Then have it audited.
7 · Mapping to learning outcomes
This single project touches most of the course's stated objectives and the concrete sessions that introduce each piece. It is, in effect, a portfolio-grade version of the weekly intermediate exercises.
- Understand the foundations — hashing (event topics & the 4-byte function selector are
keccak256truncations), digital signatures (the wallet signs every write), and the account model (EOA caller vs the two contract accounts). Sessions 1–2, 6. - Program & simulate smart contracts for currency — the ERC-20 token is a custom currency, and the escrow is a custom program governing it; both are deployed to a public testnet and driven from a Dapp. Sessions 6–9.
- Read the research / look ahead — the security analysis re-derives the DAO-hack and batchOverflow lessons that show up across the crypto-security literature you'll meet in the capstone. Sessions 10–15.
| Project piece | Course session(s) |
|---|---|
| keccak256 selectors & event topics | S2 — hashing & signatures |
| Writing & deploying Solidity | S6–S7 — Ethereum & Solidity |
| ERC-20 token mechanics | S7 — Solidity programming exercise |
| Escrow / governance logic, Dapp front-end | S8–S9 — DAOs & Dapps |
| Gas, fees, the EVM | S6 — gas as metering |
| Extensions toward privacy & Web3 | S10–S11 — ZK proofs & Web3 |
8 · Extensions
Once the base project works, each of these is a self-contained next exercise that pulls in a later session's material.
NFT rewards (ERC-721)
Replace the fungible reward with a one-of-a-kind backer NFT whose tokenURI points to
IPFS metadata. Mints become unique tokenIds. Ties into the playground's NFT module.
DAO governance
Let reward-token holders vote on whether to release funds in tranches instead of one withdrawal — the token becomes a governance token and the escrow becomes a mini-treasury. This is Sessions 8–9 made real.
Price oracle
Denominate the goal in USD and read ETH/USD from a Chainlink price feed so the target is currency-stable. Introduces the oracle trust model and the "oracle problem".
ZK private contributions
Hide individual contribution amounts behind a zero-knowledge commitment, proving "I contributed ≥ X" without revealing the amount — a bridge to the Session 10 ZK material.
Permit (EIP-2612)
Add gasless approvals to the token via signed permit messages, so contributors approve
with a signature instead of a transaction — real-world UX used by USDC and DAI.
L2 deployment
Deploy the same bytecode to an Optimism or Arbitrum testnet and compare gas costs — connects to the playground's rollups module and Session 11's scaling discussion.
9 · References
- Antonopoulos & Wood — Mastering Ethereum: Building Smart Contracts and Dapps (O'Reilly, 2018)Course-recommended. Chapters on Solidity, tokens, and security patterns map directly onto this project.
- EIP-20 — ERC-20 Token StandardThe normative specification of the six functions and two events implemented in §2.
eips.ethereum.org/EIPS/eip-20 - OpenZeppelin Contracts — ERC20, Ownable, ReentrancyGuardThe audited implementations you should inherit in production.
docs.openzeppelin.com/contracts - Solidity docs — Security Considerations & Checks-Effects-InteractionsThe canonical write-up of CEI, re-entrancy, and the 0.8 checked-arithmetic change.
docs.soliditylang.org - ethers.js v6 documentationProvider/Signer/Contract API used in the front-end.
docs.ethers.org/v6 - Hardhat documentationCompile, test, and deploy tooling for Path B.
hardhat.org/docs - The DAO Hack (2016) & batchOverflow (2018)Post-mortems that motivate the re-entrancy and overflow defences in §6.
Per the course AI policy, any GenAI assistance used to draft code for a submission must be acknowledged. This page is a study companion — for graded work, disclose tool use as the syllabus requires.