Worked Example · Token + Crowdfunding dApp

A full build-and-deploy walkthrough — Solidity contracts, an ethers.js front-end, a testnet deploy, and a gas + security analysis.

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.

Project goalA funded campaign that escrows ETH and pays contributors back in a reward token
StackSolidity 0.8.24 · ethers.js v6 · Remix / Hardhat
NetworkSepolia testnet (free faucet ETH)
ContractsRewardToken (ERC-20) + Crowdfund (escrow)
Sessions exercisedS2 (hashing), S6–7 (Solidity), S8–9 (Dapps), S10–11 (Web3)
Maps to50% "intermediate exercises" component
DifficultyIntermediate — assumes S6 Solidity basics
Est. time4–6 hours end-to-end
Solidity ^0.8.24 ERC-20 checks-effects-interactions re-entrancy guard access control events & logs ethers.js v6 MetaMask / EIP-1193 Sepolia testnet gas profiling

What you'll build, section by section

Why this project

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.

EventEmitted byWhy the dApp cares
Contributedcontribute()Update the progress bar and the contributor's running total.
Withdrawnwithdraw()Mark the campaign as successfully closed; hide the contribute button.
Refundedrefund()Confirm a contributor got their ETH back after a failed campaign.
TransferERC-20 transfer/mintThe standard token event — wallets and explorers track balances from it.
Core concept · separation of concerns

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.

Core concept · the ERC-20 interface

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.

RewardToken.solSolidity
// 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

Common pitfall · don't ship hand-rolled tokens

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.

Core concept · checks-effects-interactions (CEI)

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.

Crowdfund.solSolidity
// 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

FunctionGuardsWhat it does
contributecampaignOpen, payableRecords the sent ETH against the sender and the running total, then logs it. State-only — no external call, so no re-entrancy surface.
withdrawcampaignEnded, nonReentrantBeneficiary-only; requires the goal met; flips withdrawn before sending, then transfers the whole balance via low-level call.
refundcampaignEnded, nonReentrantOnly when the goal was missed; zeroes the contributor's record before sending ETH back — textbook CEI.
claimRewardcampaignEndedOn success, mints reward tokens 1:1 with wei contributed; the rewardClaimed flag prevents double-claims.
Key idea · the pull-over-push refund

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.

Common pitfall · 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.

Core concept · provider vs signer

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.

app.jsJavaScript · ethers v6
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

Common pitfall · never trust the front-end for security

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)

Deployment ordering · the chicken-and-egg

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)

scripts/deploy.jsJavaScript · Hardhat
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:

terminalshell
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.

test/Crowdfund.test.jsJavaScript · Mocha/Chai
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~GasWhy
deploy RewardToken~700,000One-off: stores the bytecode on-chain.
deploy Crowdfund~900,000One-off: larger bytecode + immutables in code.
contribute (first)~70,000Two fresh SSTOREs (contribution + total) + event.
contribute (repeat)~30,000Both slots already non-zero → cheaper updates.
transfer (ERC-20)~51,000Decrement one balance, increment another, emit event.
refund / withdraw~40,000One storage write + the ETH-sending call.
claimReward~75,000Flag 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

Core concept · the re-entrancy attack

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.

Attacker.sol — what we defend againstSolidity
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:

Security · integer overflow

Core concept · checked arithmetic in 0.8.x

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

RiskMitigation in this code
Re-entrancyCEI ordering + nonReentrant modifier on every ETH-sending function.
Integer overflowSolidity 0.8 checked math; unchecked only where proven safe.
Unauthorized mintmint reverts unless msg.sender == minter.
Double withdraw / claimwithdrawn and rewardClaimed flags set before the action.
Locked fundsPull-based refunds — no loop that can be DoS'd by one reverting recipient.
Calling out of ordercampaignOpen / campaignEnded modifiers gate by deadline.
Failed ETH sendLow-level call return value checked with require(ok).
Key idea

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.

Syllabus objectives exercised
  • Understand the foundations — hashing (event topics & the 4-byte function selector are keccak256 truncations), 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 pieceCourse session(s)
keccak256 selectors & event topicsS2 — hashing & signatures
Writing & deploying SolidityS6–S7 — Ethereum & Solidity
ERC-20 token mechanicsS7 — Solidity programming exercise
Escrow / governance logic, Dapp front-endS8–S9 — DAOs & Dapps
Gas, fees, the EVMS6 — gas as metering
Extensions toward privacy & Web3S10–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

GenAI acknowledgement

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.