← Web3 Hub Lab 2 · ~4 h Blockchain — Hands-on

🎟️ Event Ticketing Marketplace

A two-phase ticket economy in one contract: primary sale where the organiser locks in price and supply at deploy time, then a peer-to-peer resale market where any ticket owner can list at their own custom price. You'll learn structs, ownership transfers, the listing pattern, and how to wire a multi-action marketplace UI with ethers.js v6.

~4 hours · 1 session Continues from Lab 1 ⟠ Solidity 0.8 · ethers.js v6
▶ Start here

Welcome & prerequisites

This lab is the natural sequel to Lab 1 — Donation Pool. Where Lab 1 introduced you to MetaMask, Sepolia, Etherscan, Remix, and the basics of writing a payable Solidity contract, Lab 2 takes the same toolchain and builds something richer: a marketplace with two distinct ETH flows and asset ownership that changes hands.

🎯

What you will build

A single Solidity contract that handles two markets at once. (1) Primary sale: the deployer (event organiser) sets the ticket price and the max supply in the constructor. Anyone can buy a ticket at that fixed price; the ETH goes directly to the organiser. (2) Secondary market: any ticket owner can list their ticket at any custom price; anyone can buy a listed ticket; that ETH goes to the seller, not the organiser. The deployer earns nothing on resales — clean two-phase economy.

What you should already know

  • You've completed Lab 1 (or equivalent). MetaMask installed and on Sepolia, a Sepolia ETH balance, Remix familiar, the Etherscan plugin for verification activated.
  • Basic Solidity reflexes: pragma, constructor, payable, immutable, mappings, events, custom errors, CEI. If anything feels rusty, the Solidity Reference is one click away.
  • The ethers.js v6 DApp pattern: BrowserProvider, signer vs provider, Contract.on(event, callback) for live updates. Lab 1's §13 covers this in detail.

What this lab skips (on purpose)

No MetaMask setup walkthrough, no Sepolia faucet step-by-step, no Remix UI tour, no Etherscan-verify tutorial. Lab 1 covers all of that exhaustively — and your students will breeze through Lab 2 in a single session if they've done Lab 1 properly. If you skipped Lab 1, do that first; this lab assumes the workshop hygiene is already there.

▶ Brief

Project Brief — Event Ticketing Marketplace

Read the spec carefully. The contract is compact (about 80 lines) but introduces several patterns Lab 1 didn't touch: tracking ownership of multiple assets, listing/unlisting state, and two different "where does the ETH go?" branches in the same file.

Specification

Roles
Two: the organiser (deployer; receives every primary-sale payment) and ticket holders (anyone who bought one — can list it for resale at any price they choose). The deployer earns nothing on resales; that revenue goes entirely to whoever currently owns the ticket.
Immutable config
Set once in the constructor, never changes: owner, maxSupply, primaryPrice (in Wei). The event's name and info are stored as plain string (strings can't be immutable).
Mutable state
ticketsSold (counter; also serves as next ticket id minus 1), ownerOf[id] (mapping ticket id → current owner), listingPrice[id] (mapping ticket id → resale price; 0 = not listed). Tickets have integer ids starting at 1.
buyTicket()
payable. Anyone can call. Reverts with SoldOut if ticketsSold >= maxSupply. Reverts with WrongPayment(sent, required) if msg.value != primaryPrice (exact payment required). Otherwise: mints the next id to the caller, routes the full primaryPrice to the organiser, emits TicketBought.
listForSale(id, price)
Not payable. Reverts with NotOwner unless ownerOf[id] == msg.sender. Reverts with ZeroPrice if price == 0 (we use 0 to mean "not listed", so 0 is reserved). Otherwise sets listingPrice[id] = price and emits TicketListed. Calling it on an already-listed ticket updates the price.
cancelListing(id)
Not payable. Reverts with NotOwner if the caller isn't the current owner. Reverts with NotListed if the ticket isn't currently for sale. Otherwise deletes the listing and emits ListingCancelled. The owner keeps the ticket.
buyListing(id)
payable. Reverts with NotListed if listingPrice[id] == 0. Reverts with WrongPayment if msg.value != listingPrice[id] (exact payment, same rule as primary). Otherwise: transfers ownership (ownerOf[id] = msg.sender) and clears the listing — both before the external call (CEI). Routes the full price to the previous owner. Emits TicketResold.
View helpers
ticketsLeft() = maxSupply - ticketsSold. isListed(id) = listingPrice[id] != 0. Plus auto-generated getters on every public state variable (ownerOf(id), listingPrice(id), eventName(), etc.).
Events
TicketBought(uint256 indexed id, address indexed buyer, uint256 price), TicketListed(uint256 indexed id, address indexed seller, uint256 price), ListingCancelled(uint256 indexed id, address indexed seller), TicketResold(uint256 indexed id, address indexed from, address indexed to, uint256 price). The frontend subscribes to all four to keep the marketplace UI live.
Out of scope
Refunds (a sold ticket is final), royalties to the organiser on resale (deliberate — we keep the contract simple), time-bounded sales, allowlists / KYC, off-chain signed listings (OpenSea-style), batch buys. Discussed in §9.
🎯

The two ETH flows, side by side

This is the single most important thing to understand before reading the contract: primary buyTicket() routes ETH to the deployer/organiser — they're the one selling the new tickets. Secondary buyListing() routes ETH to the current ticket owner — they're the one re-selling. Both functions look almost identical but the recipient is different. Picture two pipes leaving the contract and going to different addresses depending on which entry point was called.

▶ Design

Design walk-through — three decisions to understand

Before writing a line of Solidity, three design choices deserve a sentence each. They're the bits that, if you don't think about them up-front, make the contract awkward later.

Decision 1 — Where does the ETH actually go?

In buyTicket(), we push msg.value straight to owner at the end of the function (after updating state — CEI). In buyListing(), we capture the current ownerOf[id] into a local seller variable before reassigning ownership, then push msg.value to that captured seller. Two pushes, two different recipients, same _send() helper underneath.

Decision 2 — How do we represent "listed for sale"?

We don't introduce a second mapping or a bool isListed. We use the existing listingPrice[id] mapping and adopt the convention price == 0 ⇔ not listed. A side effect: we reject zero-price listings with ZeroPrice() — otherwise an honest 0-ETH listing would be indistinguishable from "not listed". This is the standard sentinel pattern in Solidity: pick a default value (usually 0, address(0), or an empty string) to mean "absent", and reserve it from being a legitimate value. ERC-20's balanceOf(addr) == 0 for non-holders works the same way.

Decision 3 — CEI when the recipient is a seller, not the deployer

In Lab 1 the only ETH recipient was a known, trusted beneficiary. Here the recipient is whoever currently owns the ticket — could be a regular EOA, could be a malicious contract that bought a ticket (either from the primary sale or from another reseller) and is now listing it specifically to bait callers into buyListing. CEI is non-negotiable: update ownerOf[id] and delete listingPrice[id] before the .call to the seller. Otherwise, when our .call forwards execution to the seller's receive() / fallback(), it could re-enter buyListing(id) for the same ticket and trick our contract into paying out twice. Updating state first makes any re-entrant call revert with NotListed.

💡

Mental model: NFT-light + on-chain order book

What you're building is essentially a stripped-down ERC-721 (each ticket has a unique id and a single owner; ownership transfers atomically with payment) plus a one-line on-chain order book (listingPrice[id]). A real production NFT marketplace adds approvals, royalties, off-chain signed listings, and a few hundred more lines. The core idea is what you'll write today, and once you've internalised this pattern, reading OpenSea's Seaport contract becomes much less mysterious.

▶ Build

Scaffold — try it yourself first

Create EventTickets.sol in Remix and paste the scaffold below. Fill in the seven TODO blocks. Give yourself 30–40 minutes before opening §5 — the muscle memory you build here is worth the effort even if your version doesn't pass every test on the first run.

EventTickets.sol — scaffold Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

contract EventTickets {
    // ── Immutable config (locked at construction) ─────────────
    address public immutable owner;          // deployer; receives primary-sale revenue
    uint256 public immutable maxSupply;
    uint256 public immutable primaryPrice;   // in wei

    // ── Event metadata (string ⇒ can't be immutable) ──────────
    string public eventName;
    string public eventInfo;        // free text: date, venue, description, …

    // ── Mutable state ─────────────────────────────────────────
    uint256 public ticketsSold;                    // also "next id" − 1; ids start at 1
    mapping(uint256 => address) public ownerOf;     // id → current owner
    mapping(uint256 => uint256) public listingPrice; // id → resale price; 0 = not listed

    // ── Events ────────────────────────────────────────────────
    event TicketBought(uint256 indexed id, address indexed buyer, uint256 price);
    event TicketListed(uint256 indexed id, address indexed seller, uint256 price);
    event ListingCancelled(uint256 indexed id, address indexed seller);
    event TicketResold(uint256 indexed id, address indexed from, address indexed to, uint256 price);

    // ── Errors ────────────────────────────────────────────────
    error SoldOut();
    error WrongPayment(uint256 sent, uint256 required);
    error NotOwner();
    error NotListed();
    error ZeroPrice();
    error TransferFailed();

    constructor(
        string memory _name,
        string memory _info,
        uint256 _maxSupply,
        uint256 _primaryPriceWei
    ) {
        // TODO 1: assign the immutables (owner ← msg.sender; the other three from args)
        //         and the two strings.
    }

    // ── Primary sale ──────────────────────────────────────────
    function buyTicket() external payable returns (uint256 id) {
        // TODO 2:
        // - revert SoldOut() if ticketsSold >= maxSupply
        // - revert WrongPayment(msg.value, primaryPrice) if msg.value != primaryPrice
        // - id = ++ticketsSold; ownerOf[id] = msg.sender;
        // - send primaryPrice to owner via _send
        // - emit TicketBought(id, msg.sender, primaryPrice)
    }

    // ── Secondary market — list ───────────────────────────────
    function listForSale(uint256 id, uint256 price) external {
        // TODO 3:
        // - revert NotOwner() if ownerOf[id] != msg.sender
        // - revert ZeroPrice() if price == 0  (0 is our "not listed" sentinel)
        // - listingPrice[id] = price
        // - emit TicketListed(id, msg.sender, price)
    }

    // ── Secondary market — cancel ─────────────────────────────
    function cancelListing(uint256 id) external {
        // TODO 4:
        // - revert NotOwner() if ownerOf[id] != msg.sender
        // - revert NotListed() if listingPrice[id] == 0
        // - delete listingPrice[id]  (gas-cheaper than setting to 0; partial refund)
        // - emit ListingCancelled(id, msg.sender)
    }

    // ── Secondary market — buy ────────────────────────────────
    function buyListing(uint256 id) external payable {
        // TODO 5:
        // - uint256 price = listingPrice[id];
        // - revert NotListed() if price == 0
        // - revert WrongPayment(msg.value, price) if msg.value != price
        // - capture address seller = ownerOf[id]  BEFORE you overwrite it
        // - Effects (CEI): ownerOf[id] = msg.sender; delete listingPrice[id];
        // - Interactions: _send(payable(seller), price)
        // - emit TicketResold(id, seller, msg.sender, price)
    }

    // ── ETH helper (modern .call pattern) ─────────────────────
    function _send(address payable to, uint256 amount) private {
        // TODO 6: (bool ok, ) = to.call{value: amount}("");
        //         if (!ok) revert TransferFailed();
    }

    // ── Views ─────────────────────────────────────────────────
    function ticketsLeft() external view returns (uint256) {
        // TODO 7: return maxSupply - ticketsSold
    }

    function isListed(uint256 id) external view returns (bool) {
        return listingPrice[id] != 0;
    }
}

Hints

  • Capturing the seller before overwriting: in buyListing, you must read ownerOf[id] into a local seller variable before setting ownerOf[id] = msg.sender. Otherwise by the time you call _send, ownership has already changed and you'd be paying the buyer.
  • delete vs = 0: for a uint256 in a mapping, delete listingPrice[id] and listingPrice[id] = 0 compile to the same bytecode and trigger the same SSTORE refund when clearing a non-zero slot. The reason to prefer delete is readability: it expresses your intent ("remove this entry"), and on complex types it does the right thing — for a struct, delete resets every field; for an array, delete resets length to 0.
  • Exact-payment rule: both buyTicket and buyListing require msg.value to equal the expected amount exactly. The frontend will always know the right number, so this is safer than refunding excess (avoids one external call per purchase).
▶ Build

Reference solution — EventTickets.sol

Compare yours with this. The structure follows the scaffold exactly; the design notes below the code explain the choices that matter.

EventTickets.sol — solution Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

/// @title  EventTickets
/// @notice Primary-sale tickets (revenue to organiser) plus a peer-to-peer
///         resale market (revenue to each ticket's current owner).
contract EventTickets {
    address public immutable owner;
    uint256 public immutable maxSupply;
    uint256 public immutable primaryPrice;

    string public eventName;
    string public eventInfo;

    uint256 public ticketsSold;
    mapping(uint256 => address) public ownerOf;
    mapping(uint256 => uint256) public listingPrice;

    event TicketBought(uint256 indexed id, address indexed buyer, uint256 price);
    event TicketListed(uint256 indexed id, address indexed seller, uint256 price);
    event ListingCancelled(uint256 indexed id, address indexed seller);
    event TicketResold(uint256 indexed id, address indexed from, address indexed to, uint256 price);

    error SoldOut();
    error WrongPayment(uint256 sent, uint256 required);
    error NotOwner();
    error NotListed();
    error ZeroPrice();
    error TransferFailed();

    constructor(
        string memory _name,
        string memory _info,
        uint256 _maxSupply,
        uint256 _primaryPriceWei
    ) {
        owner        = msg.sender;
        maxSupply    = _maxSupply;
        primaryPrice = _primaryPriceWei;
        eventName    = _name;
        eventInfo    = _info;
    }

    function buyTicket() external payable returns (uint256 id) {
        if (ticketsSold >= maxSupply)         revert SoldOut();
        if (msg.value != primaryPrice)        revert WrongPayment(msg.value, primaryPrice);

        id = ++ticketsSold;
        ownerOf[id] = msg.sender;

        _send(payable(owner), primaryPrice);
        emit TicketBought(id, msg.sender, primaryPrice);
    }

    function listForSale(uint256 id, uint256 price) external {
        if (ownerOf[id] != msg.sender) revert NotOwner();
        if (price == 0)                  revert ZeroPrice();

        listingPrice[id] = price;
        emit TicketListed(id, msg.sender, price);
    }

    function cancelListing(uint256 id) external {
        if (ownerOf[id] != msg.sender) revert NotOwner();
        if (listingPrice[id] == 0)      revert NotListed();

        delete listingPrice[id];
        emit ListingCancelled(id, msg.sender);
    }

    function buyListing(uint256 id) external payable {
        uint256 price = listingPrice[id];
        if (price == 0)             revert NotListed();
        if (msg.value != price)     revert WrongPayment(msg.value, price);

        address seller = ownerOf[id];   // capture BEFORE the overwrite

        // Effects (CEI): mutate state before the external transfer
        ownerOf[id] = msg.sender;
        delete listingPrice[id];

        // Interactions: pay the seller
        _send(payable(seller), price);
        emit TicketResold(id, seller, msg.sender, price);
    }

    function _send(address payable to, uint256 amount) private {
        (bool ok, ) = to.call{value: amount}("");
        if (!ok) revert TransferFailed();
    }

    function ticketsLeft() external view returns (uint256) { return maxSupply - ticketsSold; }
    function isListed(uint256 id) external view returns (bool) { return listingPrice[id] != 0; }
}

Design notes

CEI on resale
In buyListing, we capture seller, then update ownerOf[id] and delete listingPrice[id], then pay the seller. If the seller is a malicious contract that re-enters buyListing(id) in its fallback, the second call sees listingPrice[id] == 0 and reverts with NotListed. The attacker pays nothing on the re-entry (revert undoes everything), and the original sale completes cleanly. Without CEI, a re-entrant call could repeatedly buy the same ticket and drain the buyer.
No royalty
The contract sends 100% of every resale to the current ticket owner. The organiser earns nothing on the secondary market — by design. Adding a flat percentage (e.g. 5%) would be a 3-line change: compute fee = (price * 5) / 100, send fee to owner, send price - fee to the seller. Out of scope here for pedagogical clarity (one ETH recipient per function = one mental model to track).
Exact payment
Both buy paths require msg.value to equal the expected amount exactly. The frontend always knows the right number, so this is simpler than refunding excess. If you want to allow overpayment (defensive UX for users who type a wrong amount), capture the excess and route it back to the buyer with a third _send call — just remember each call adds gas and a re-entrancy edge to think about.
Why delete over = 0
delete listingPrice[id] writes the type's default (0). For a uint256 in a mapping the resulting bytecode is identical to listingPrice[id] = 0 — both produce a single SSTORE with value 0 and both trigger the same gas refund when clearing a previously non-zero slot (~4800 gas post-London EIP-3529, capped at 1/5 of the transaction's gas used). The reason to prefer delete is readability: it expresses intent ("remove this entry") and it correctly resets composite types — delete s on a struct zeroes every field, delete arr on a dynamic array sets its length back to 0. Habit: use delete any time you're "removing" an entry.
No onlyOwner here either
Same trust-minimisation philosophy as Lab 1. The organiser has no post-deploy powers: cannot pause sales, cannot change the price, cannot cancel a ticket they sold, cannot freeze a resale they don't like. The contract is fully described by its bytecode at deploy time. The only thing they can do is what every other ticket holder can do — list tickets they own.
Indexed event params
We indexed id, buyer/seller, and (on TicketResold) both from and to. Three indexed params is the maximum the EVM allows per event. The frontend can subscribe to "every event for ticket 42" or "every event where I'm the seller" with O(1) topic filters — much cheaper than scanning all logs.
▶ Test

Verify in the Remix VM (10 minutes)

Before deploying to Sepolia, run these six manual tests on the Remix VM. Deploy with constructor args _name = "DevConf 2026", _info = "Lyon · 2026-09-15 · Auditorium A", _maxSupply = 3, _primaryPriceWei = 10000000000000000 (0.01 ETH; pick ether in Remix's unit selector and type 0.01).

🧪

The six tests

  1. Primary buy. (Fresh deploy.) Switch to Account 2, set VALUE to 0.01 ether, call buyTicket → succeeds. Call ownerOf(1) → returns Account 2's address. Call ticketsLeft()2. The terminal shows the TicketBought event.
  2. Wrong payment. Still on Account 2, set VALUE to 0.02 ether, call buyTicket → must revert with WrongPayment(20000000000000000, 10000000000000000). Now try 0 ether — same revert.
  3. Sold out. Buy from Account 3 and Account 4 at 0.01 ether. Try Account 5 → must revert with SoldOut. (We deployed with maxSupply = 3.)
  4. List, then cancel. Switch to Account 2 (owns ticket #1). Call listForSale(1, 50000000000000000) — that's 0.05 ETH in wei. Terminal: TicketListed. Verify with isListed(1)true, listingPrice(1)5e16 (Remix's scientific-notation rendering of 50000000000000000 wei = 0.05 ETH). Call cancelListing(1)ListingCancelled; isListed(1)false. Verify the wrong-owner path: switch to Account 5, try listForSale(1, …) → must revert NotOwner.
  5. Resale, happy path. On Account 2, list ticket #1 again at 0.05 ether. Switch to Account 5, set VALUE to 0.05 ether, call buyListing(1). Watch Account 2's balance go up by ~0.05 ETH (the resale price, minus the small gas they spent on the listing tx) and Account 5's balance drop by 0.05 ETH plus their own gas. Call ownerOf(1) → Account 5. isListed(1)false. Terminal: TicketResold.
  6. Resale, error paths. Try buyListing(1) again (ticket no longer listed) → NotListed. Have Account 5 (the new owner) list at 0.07 ether, then Account 6 calls buyListing(1) with 0.06 etherWrongPayment(60000000000000000, 70000000000000000). Final invariant: at every step, exactly one address owned each ticket, and the right person received the ETH.
🎯

Pass checklist before moving to Sepolia

All six tests pass; all reverts carry the right error name and (where applicable) the right decoded arguments; the four events fire when and only when they should; address(this).balance in Remix stays at zero at the end of every successful test (we route every wei out — the contract never holds funds). If any of those isn't true, fix it now — Sepolia gas is faucet ETH but verification mistakes are still annoying.

▶ Ship

Deploy to Sepolia & verify on Etherscan

Same flow as Lab 1's §12. Brief recap here — if anything is unclear, jump back to Lab 1 §12 for the click-by-click walkthrough.

  1. Compile with optimisation enabled. Solidity Compiler → Advanced configurations → Enable optimization, 200 runs. Match these exactly when you verify on Etherscan.
  2. Confirm MetaMask is on Sepolia. Network badge at the top of the popup. If not, switch back to Sepolia from the dropdown.
  3. Switch Environment in Remix to Injected Provider — MetaMask.
  4. Set constructor args. Click the small arrow next to the orange Deploy button to expand:
    • _name: "DevConf 2026" (or any string you like)
    • _info: "Lyon · 2026-09-15 · Auditorium A" (date, venue, etc., free text)
    • _maxSupply: 20
    • _primaryPriceWei: 10000000000000000 — or pick ether in Remix's unit selector and type 0.01
  5. Click Deploy. Confirm in MetaMask. Wait ~12 s (one block).
  6. Copy the deployed contract address and open it in sepolia.etherscan.io.
  7. Verify with the Remix Etherscan plugin (Plugin Manager → activate, then paste your API key from etherscan.io/myapikey, pick Sepolia, click Verify). The plugin auto-fills source, compiler version, optimisation, and the ABI-encoded constructor args. Within ~30 s your contract page on Etherscan has working Read Contract and Write Contract tabs with full source visible.

Smoke-test from Etherscan

Under Write Contract, click Connect to Web3 (uses MetaMask) and call buyTicket with payableAmount = 0.01. Confirm in MetaMask. Once mined, the Events tab shows the TicketBought log; Read Contract shows ticketsLeft() = 19 and ownerOf(1) = your address. That is your first verified ticketing contract on a public chain.

▶ Frontend

DApp Frontend — the marketplace UI

Lab 1's DApp had a single donate button. Here we wire four user actions in the same page: buy a primary ticket, list one of your tickets, cancel a listing, and buy a listed ticket from someone else. Plus live updates from four event subscriptions. Same toolchain — vanilla HTML + ethers.js v6 — just more wiring.

⚠️

Snippets are illustrative

Same convention as Lab 1: per-step snippets show only the new lines and declare locals with const. The full file at the bottom uses module-scope let so the four button handlers share provider, signer, readonly, writable. Read each step for understanding; copy the final file to run.

Step 1 — Skeleton

A small event header (name, info, tickets left, primary price), a single card grid that renders one card per ticket slot (#1..#maxSupply), and a log panel for confirmations. Each card carries its own status badge and action button — there is no separate "My tickets" list and no marketplace table; the same grid shows everything, and the action on each card adapts to who's looking. Un-minted slots all share the same "Buy from organiser" behaviour: clicking any of them calls buyTicket(), and the contract mints the next id in sequence regardless of which Available card was clicked.

index.html — skeleton HTML
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Event Tickets</title></head>
<body>
  <!-- Static defaults are visible BEFORE connect; refresh() overwrites them with on-chain data. -->
  <h1 id="eventName">Event Tickets</h1>
  <p id="eventInfo">Connect your wallet to load the event details.</p>

  <button id="connect">Connect MetaMask</button>
  <span id="account">not connected</span>

  <p>Tickets left: <code id="ticketsLeft">—</code> / <code id="maxSupply">—</code>
     · Primary price: <code id="primaryPrice">—</code> ETH</p>

  <!-- One card per ticket slot (1..maxSupply):
       minted ones reflect on-chain state, un-minted ones are "Available". -->
  <div id="ticketGrid"></div>

  <pre id="log"></pre>

  <script type="module">
    // connect + refresh (renders cards) + four per-card handlers + subscriptions
  </script>
</body>
</html>

Step 2 — Connect (identical to Lab 1)

Same BrowserProvider + eth_requestAccounts + getSigner flow. If you've done Lab 1, this is muscle memory now. The reference is Lab 1 §13.

Step 3 — Read state

Two reads, then we render the whole grid. (1) Event metadata + supply: one call per view, batched with Promise.all. (2) Per-ticket state: for every minted id (1..ticketsSold), fetch ownerOf(id) and listingPrice(id) in parallel. Un-minted slots (ticketsSold+1..maxSupply) don't need any on-chain query — they all share the same "Available from organiser at primaryPrice" state, so we can render them straight from data we already have. For small maxSupply this is fast (one RPC round-trip); for thousands of tickets you'd switch to an event-log scan via contract.queryFilter instead of iterating ids.

Step 3 — read state & render the card grid JS
async function refreshGrid() {
  // (1) Header + supply + primary price
  const [name, info, sold, max, priceWei] = await Promise.all([
    readonly.eventName(),
    readonly.eventInfo(),
    readonly.ticketsSold(),
    readonly.maxSupply(),
    readonly.primaryPrice(),
  ]);
  renderHeader({ name, info, sold, max, priceWei });

  const soldN = Number(sold);
  const maxN  = Number(max);

  // (2) Fetch owner + listingPrice for EVERY minted id in parallel
  const mintedIds = Array.from({ length: soldN }, (_, i) => i + 1);
  const [owners, prices] = await Promise.all([
    Promise.all(mintedIds.map(id => readonly.ownerOf(id))),
    Promise.all(mintedIds.map(id => readonly.listingPrice(id))),
  ]);

  // (3) Render the grid: minted cards first, then "Available" cards
  const cards = [];
  for (let i = 0; i < soldN; i++) {
    cards.push(mintedCard(mintedIds[i], owners[i], prices[i]));
  }
  for (let id = soldN + 1; id <= maxN; id++) {
    cards.push(availableCard(id, priceWei));
  }
  renderGrid(cards);
}

Step 4 — Four write actions, one per card

Each card renders one button whose label and handler are chosen from this set of four. They map one-to-one to the contract's four user-facing functions; the only architectural change from Lab 1 is that buttons are now attached to individual cards instead of a fixed list, so the same handler is bound dynamically when we render the grid. All four follow the same shape: build the tx via writable, log the hash, await tx.wait(), refresh. Errors surface as e.shortMessage from ethers — which decodes our custom errors (e.g. NotListed(...), WrongPayment(sent, required)) into readable text.

Step 4 — the four handlers each card binds to JS
// (1) Buy from the organiser — fired by an "Available" card's button.
//     The contract mints the next id in sequence regardless of which card was clicked.
async function onBuyPrimary(priceWei) {
  const tx = await writable.buyTicket({ value: priceWei });   // exact amount
  log("buy tx:", tx.hash);
  await tx.wait();
}

// (2) List one of MY tickets — fired by a "Yours" card's "List for sale" button.
async function onList(id) {
  const ethAmount = prompt("Sell ticket #" + id + " for how many ETH?");
  if (!ethAmount) return;
  const tx = await writable.listForSale(id, parseEther(ethAmount));
  log("list tx:", tx.hash);
  await tx.wait();
}

// (3) Cancel a listing — fired by a "Listed (you)" card's "Cancel listing" button.
async function onCancel(id) {
  const tx = await writable.cancelListing(id);
  log("cancel tx:", tx.hash);
  await tx.wait();
}

// (4) Buy a listed ticket — fired by an "On sale" card's "Buy from owner" button.
//     We already have priceWei from the card's render data; no extra read needed.
async function onBuyListing(id, priceWei) {
  const tx = await writable.buyListing(id, { value: priceWei });
  log("buy-listing tx:", tx.hash);
  await tx.wait();
}

Step 5 — Live subscriptions to four events

Subscribe once to each event; every time it fires anywhere on the chain for our contract, we re-run refresh() — which re-renders the whole grid from current on-chain state. The user experience: when another user mints, lists, cancels, or buys a ticket, the affected card(s) in your grid flip to the new state within ~15 seconds (one block + a few hundred milliseconds of polling) without any page reload.

Step 5 — subscribe JS
function subscribe() {
  readonly.on("TicketBought", (id, buyer, price) => {
    log("🎟️ TicketBought · id", id.toString(), "· buyer", buyer);
    refresh();
  });
  readonly.on("TicketListed", (id, seller, price) => {
    log("📢 TicketListed · id", id.toString(), "@", formatEther(price), "ETH");
    refresh();
  });
  readonly.on("ListingCancelled", (id, seller) => {
    log("🚫 ListingCancelled · id", id.toString());
    refresh();
  });
  readonly.on("TicketResold", (id, from, to, price) => {
    log("💱 TicketResold · id", id.toString(), from, "→", to);
    refresh();
  });
}
🛰️

Why this is fun

Open the DApp in three browsers, each with a different MetaMask account. Account A clicks Buy on an "Available" card → ticket #1 mints to A; A's grid flips that card to "Yours". Account A clicks List for sale on the same card, enters 0.05 ETH. Within ~15 seconds, Account B and C see card #1 flip in their own grids — to "On sale" with a Buy button. Account B clicks it → A's ETH balance jumps by 0.05; card #1 simultaneously updates in all three grids again, now showing B as the owner. Three independent UIs, all watching the same on-chain state, all repainting themselves card-by-card. That's a real-time multiplayer app with zero backend.

Putting it all together — the complete index.html

Save the file below as index.html, paste your deployed Sepolia contract address into CONTRACT_ADDRESS, and serve the folder with python3 -m http.server (or VS Code's Live Server, or npx serve — anything that serves over HTTP, since browsers block ES modules from file://). Open http://localhost:8000 in two browsers with different MetaMask accounts to see the live marketplace in action.

index.html HTML
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Event Tickets</title>
  <style>
    body  { font-family: system-ui, sans-serif; max-width: 980px; margin: 2rem auto; padding: 0 1rem; }
    h1    { margin-bottom: 0; }
    p#eventInfo { color: #666; margin-top: 0.3rem; }
    button { padding: 0.45rem 0.9rem; cursor: pointer; font-size: 0.92rem; }
    button:disabled { opacity: 0.5; cursor: not-allowed; }
    code  { background: #f3f3f3; padding: 0 4px; border-radius: 3px; }
    .summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin: 0.8rem 0 1.2rem; color: #444; }

    .ticket-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin: 12px 0 24px; }
    .ticket { border: 1px solid #ddd; border-radius: 10px; padding: 14px; background: #fafafa; display: flex; flex-direction: column; gap: 8px; }
    .ticket.is-available { border-color: #22d3ee; background: #ecfeff; }
    .ticket.is-listed    { border-color: #f59e0b; background: #fff7ed; }
    .ticket.is-yours     { box-shadow: inset 4px 0 0 #8b5cf6; }
    .ticket-head { display: flex; justify-content: space-between; align-items: center; }
    .ticket-num  { font-weight: 700; font-size: 1.05em; }
    .badge { font-size: 0.7em; padding: 2px 8px; border-radius: 12px; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; color: white; }
    .badge-available { background: #06b6d4; }
    .badge-listed    { background: #f59e0b; }
    .badge-unlisted  { background: #9ca3af; }
    .badge-yours     { background: #8b5cf6; }
    .ticket-owner { font-family: ui-monospace, monospace; font-size: 0.85em; color: #555; }
    .ticket-price { font-size: 1.05em; font-weight: 600; }
    .ticket button { width: 100%; }

    #log { background: #0a0a0a; color: #b6f5b0; padding: 0.8rem; border-radius: 6px;
           font-family: ui-monospace, monospace; font-size: 0.85rem; white-space: pre-wrap;
           max-height: 220px; overflow: auto; margin-top: 1rem; }
    #log:empty { display: none; }  /* hide the log box until the first event */
    .empty-grid { grid-column: 1 / -1; color: #888; font-size: 0.92em; padding: 1.5rem;
                  border: 1px dashed #ccc; border-radius: 10px; text-align: center; }
  </style>
</head>
<body>
  <h1 id="eventName">Event Tickets</h1>
  <p  id="eventInfo">Connect your MetaMask wallet to load the event details and the available tickets.</p>

  <div><button id="connect">Connect MetaMask</button>
    <span id="account">not connected</span></div>

  <div class="summary">
    <span>Tickets left: <code id="ticketsLeft">—</code> / <code id="maxSupply">—</code></span>
    <span>Primary price: <code id="primaryPrice">—</code> ETH</span>
  </div>

  <div id="ticketGrid" class="ticket-grid">
    <div class="empty-grid">Tickets will appear here after you connect.</div>
  </div>

  <pre id="log"></pre>

  <script type="module">
  import { BrowserProvider, Contract, formatEther, parseEther }
    from "https://cdn.jsdelivr.net/npm/ethers@6/+esm";

  // ⚠ Replace this placeholder with the address Remix gave you after
  //   deploying EventTickets.sol on Sepolia (42 chars, "0x" + 40 hex).
  const CONTRACT_ADDRESS = "0xPASTE_YOUR_SEPOLIA_CONTRACT_ADDRESS_HERE";
  const ABI = [
    "function eventName() view returns (string)",
    "function eventInfo() view returns (string)",
    "function maxSupply() view returns (uint256)",
    "function primaryPrice() view returns (uint256)",
    "function ticketsSold() view returns (uint256)",
    "function ticketsLeft() view returns (uint256)",
    "function ownerOf(uint256) view returns (address)",
    "function listingPrice(uint256) view returns (uint256)",
    "function buyTicket() payable",
    "function listForSale(uint256,uint256)",
    "function cancelListing(uint256)",
    "function buyListing(uint256) payable",
    "event TicketBought(uint256 indexed id, address indexed buyer, uint256 price)",
    "event TicketListed(uint256 indexed id, address indexed seller, uint256 price)",
    "event ListingCancelled(uint256 indexed id, address indexed seller)",
    "event TicketResold(uint256 indexed id, address indexed from, address indexed to, uint256 price)"
  ];

  const log = (...a) => { document.getElementById("log").textContent += a.join(" ") + "\n"; };
  const short = (a) => a ? (a.slice(0, 6) + "…" + a.slice(-4)) : "—";
  const btn   = (label, onClick, disabled = false) => {
    const b = document.createElement("button");
    b.textContent = label;
    if (disabled) b.disabled = true;
    else if (onClick) b.addEventListener("click", onClick);
    return b;
  };

  let provider, signer, account, readonly, writable;

  document.getElementById("connect").addEventListener("click", async () => {
    if (!window.ethereum) { alert("Install MetaMask"); return; }
    // Friendly check: did the student remember to replace the placeholder?
    if (!/^0x[0-9a-fA-F]{40}$/.test(CONTRACT_ADDRESS)) {
      alert("Edit index.html first: replace CONTRACT_ADDRESS with your deployed Sepolia contract address (0x + 40 hex chars).");
      return;
    }
    try {
      provider = new BrowserProvider(window.ethereum);
      await provider.send("eth_requestAccounts", []);
      signer   = await provider.getSigner();
      account  = await signer.getAddress();
      document.getElementById("account").textContent = account;

      readonly = new Contract(CONTRACT_ADDRESS, ABI, provider);
      writable = new Contract(CONTRACT_ADDRESS, ABI, signer);

      await refresh();
      subscribe();
      log("connected as", account);
    } catch (e) { log("connect error:", e.shortMessage || e.message); }
  });

  async function refresh() {
    // (1) Event header + supply + primary price
    const [name, info, sold, max, priceWei] = await Promise.all([
      readonly.eventName(),
      readonly.eventInfo(),
      readonly.ticketsSold(),
      readonly.maxSupply(),
      readonly.primaryPrice()
    ]);
    document.getElementById("eventName").textContent    = name;
    document.getElementById("eventInfo").textContent    = info;
    document.getElementById("ticketsLeft").textContent  = (max - sold).toString();
    document.getElementById("maxSupply").textContent    = max.toString();
    document.getElementById("primaryPrice").textContent = formatEther(priceWei);

    const soldN = Number(sold);
    const maxN  = Number(max);
    const me    = (account || "").toLowerCase();

    // (2) For every minted id, fetch owner + listingPrice in parallel
    const mintedIds = Array.from({ length: soldN }, (_, i) => i + 1);
    const [owners, prices] = await Promise.all([
      Promise.all(mintedIds.map(id => readonly.ownerOf(id))),
      Promise.all(mintedIds.map(id => readonly.listingPrice(id)))
    ]);

    // (3) Render the whole grid: minted cards first, then "Available" cards
    const grid = document.getElementById("ticketGrid");
    grid.innerHTML = "";
    for (let i = 0; i < soldN; i++) {
      grid.appendChild(mintedCard(mintedIds[i], owners[i], prices[i], me));
    }
    for (let id = soldN + 1; id <= maxN; id++) {
      grid.appendChild(availableCard(id, priceWei));
    }
  }

  // ── Card renderers ────────────────────────────────────
  function mintedCard(id, owner, listingP, me) {
    const isYours  = owner.toLowerCase() === me;
    const isListed = listingP !== 0n;

    const card = document.createElement("div");
    card.className = "ticket" + (isListed ? " is-listed" : "") + (isYours ? " is-yours" : "");

    const status     = isYours ? (isListed ? "Listed (you)" : "Yours")
                              : (isListed ? "On sale"      : "Not for sale");
    const badgeClass = isYours ? "badge-yours"
                              : (isListed ? "badge-listed" : "badge-unlisted");
    const ownerLine = short(owner) + (isYours ? " (you)" : "");
    const priceLine = isListed ? formatEther(listingP) + " ETH" : "—";

    card.innerHTML =
      '<div class="ticket-head">' +
        '<span class="ticket-num">#' + id + '</span>' +
        '<span class="badge ' + badgeClass + '">' + status + '</span>' +
      '</div>' +
      '<div class="ticket-owner">Owner: ' + ownerLine + '</div>' +
      '<div class="ticket-price">' + priceLine + '</div>';

    if (isYours) {
      card.appendChild(btn(
        isListed ? "Cancel listing" : "List for sale",
        () => isListed ? onCancel(id) : onList(id)
      ));
    } else if (isListed) {
      card.appendChild(btn("Buy from owner", () => onBuyListing(id, listingP)));
    } else {
      card.appendChild(btn("Not for sale", null, true));
    }
    return card;
  }

  function availableCard(id, priceWei) {
    const card = document.createElement("div");
    card.className = "ticket is-available";
    card.innerHTML =
      '<div class="ticket-head">' +
        '<span class="ticket-num">#' + id + '</span>' +
        '<span class="badge badge-available">Available</span>' +
      '</div>' +
      '<div class="ticket-owner">From organiser</div>' +
      '<div class="ticket-price">' + formatEther(priceWei) + ' ETH</div>';
    card.appendChild(btn("Buy", () => onBuyPrimary(priceWei)));
    return card;
  }

  // ── Write actions (one per card type) ─────────────────
  async function onBuyPrimary(priceWei) {
    try {
      const tx = await writable.buyTicket({ value: priceWei });
      log("buy tx:", tx.hash); await tx.wait();  log("✅ confirmed"); await refresh();
    } catch (e) { log("error:", e.shortMessage || e.message); }
  }

  async function onList(id) {
    try {
      const ethAmount = prompt("Sell ticket #" + id + " for how many ETH? (e.g. 0.05)");
      if (!ethAmount) return;
      const tx = await writable.listForSale(id, parseEther(ethAmount));
      log("list tx:", tx.hash); await tx.wait();  await refresh();
    } catch (e) { log("error:", e.shortMessage || e.message); }
  }

  async function onCancel(id) {
    try {
      const tx = await writable.cancelListing(id);
      log("cancel tx:", tx.hash); await tx.wait();  await refresh();
    } catch (e) { log("error:", e.shortMessage || e.message); }
  }

  async function onBuyListing(id, priceWei) {
    try {
      const tx = await writable.buyListing(id, { value: priceWei });
      log("buy-listing tx:", tx.hash); await tx.wait();  await refresh();
    } catch (e) { log("error:", e.shortMessage || e.message); }
  }

  // ── Live event subscriptions — every event triggers a grid refresh ─
  function subscribe() {
    readonly.on("TicketBought",    (id, buyer, price) => { log("🎟️ TicketBought · #" + id); refresh(); });
    readonly.on("TicketListed",    (id, seller, price) => { log("📢 TicketListed · #" + id + " @ " + formatEther(price) + " ETH"); refresh(); });
    readonly.on("ListingCancelled", (id, seller) => { log("🚫 ListingCancelled · #" + id); refresh(); });
    readonly.on("TicketResold",    (id, from, to, price) => { log("💱 TicketResold · #" + id); refresh(); });
  }
  </script>
</body>
</html>

How to run it

  1. Save as index.html. Replace CONTRACT_ADDRESS with your verified Sepolia address.
  2. Serve the folder with any tiny static server: python3 -m http.server (or VS Code Live Server, or npx serve). Visit http://localhost:8000.
  3. Click Connect MetaMask. The event header and (empty) marketplace populate.
  4. Click Buy on any "Available" card (they all do the same thing — the contract mints the next id in sequence). After ~12 s, the leftmost "Available" card flips to "Yours" with your address. Click its List for sale button and enter a price.
  5. Open the same URL in a second browser instance with a different MetaMask account (different browser, Chrome profile, or a second account inside the same MetaMask). Within ~15 s, the card you listed flips to "On sale" in the other browser with a Buy from owner button. Click it — the original seller's MetaMask balance jumps, and the card simultaneously updates in both UIs without a refresh.
▶ Wrap-up

Wrap-up, variations, and what's next

If you finished §8 with two browsers buying tickets off each other through a verified Sepolia contract, you've now shipped a richer DApp than most weekend hackathon projects. Take a moment.

🏆

You can now…

  • Design a contract with two distinct ETH flows in the same file, routed to different recipients depending on the entry point.
  • Track per-asset ownership via mapping(id => address) and transfer ownership atomically with payment.
  • Implement the listing pattern: a single uint mapping that serves both as price storage and as the "is this listed?" sentinel.
  • Apply CEI in a non-trivial setting — where the ETH recipient is a counterparty you don't control, not a known trusted address.
  • Build a multi-action DApp frontend with four button handlers and four event subscriptions, all sharing module-scope state.
  • Read on-chain state in parallel (Promise.all over per-id queries) and render a card grid that updates live from event subscriptions.

Variations to propose to your students

Same scaffold, different brief. Each of these is a few-hour extension and a great way to consolidate the patterns from Lab 1 + Lab 2.

💰

Organiser royalty on resales

Add a 5% fee on every buyListing that routes back to owner. Three new lines: compute fee = (price * 5) / 100, send fee to owner, send price - fee to the seller. Now the contract has two recipients in one function — clean exercise on multi-call CEI.

Time-bounded primary sale

Add uint256 immutable salesEndAt set in the constructor. buyTicket reverts after block.timestamp >= salesEndAt. The secondary market continues forever. Teaches the block.timestamp deadline pattern.

👥

Allowlist with merkle proofs

Replace "anyone can buy" with "only addresses in a merkle tree can buy the primary supply". The deployer commits the merkle root in the constructor; buyers supply a proof. Standard NFT-launchpad pattern, ~30 extra lines.

🪪

Non-transferable tickets

Drop listForSale / cancelListing / buyListing entirely. Tickets are bound to the original buyer's address forever — anti-scalping. Use ownerOf[id] at the gate via a signed message. Closer to "soulbound" tokens.

Going deeper

Every keyword used in Lab 2 — immutable, mapping, delete, indexed, .call, custom errors with arguments — has a dedicated section. Bookmark it.
ERC-20 from scratch. What you'd add to EventTickets to make it an ERC-721 (which already standardises ownership + transfer).
Security. The CEI you wrote here generalises; The DAO hack and a few hundred million dollars of follow-up exploits all fell to the same reentrancy bug we already neutralised.
Layer 2. Re-deploy EventTickets on Base Sepolia or Arbitrum Sepolia. Same code, 10–100× cheaper gas. The frontend just changes one chainId.
Suggested next step

Pick one of the four variations above and implement it on top of your verified contract. Adding a feature to a contract you already understand is the best way to lock in the patterns.

← Back to the Web3 Hub
📦

Lab deliverables (suggested grading rubric)

  • Sepolia contract address, verified on Etherscan (30%).
  • Source file with immutable config, custom errors with arguments, CEI in buyListing, the listing-price sentinel pattern (25%).
  • Working DApp hosted locally — connect, buy primary, list, cancel, buy resale, live event updates (30%).
  • One-page write-up: pick one variation from §9, describe what you'd change in the contract and the frontend (no need to implement it — just design it) (15%).