CS414 Fundamentals of Blockchain
Event tickets as ERC-721 assets on a chain we control, announced across a peer-to-peer network we built ourselves, with a resale price ceiling that no organizer, reseller, or platform operator can override.
| Run it | getting-started.md — setup, MetaMask, buying and reselling end to end |
| Architecture | docs/architecture.md |
| Demo runbook | docs/demo-scenarios.md |
| How we built it | docs/development-process.md |
| How it works, in depth | docs/technical-walkthrough.md |
| Presentation & defence plan | docs/presentation-plan.md |
| Hosted page | ticket-chain-ten.vercel.app — a landing page only. The chain and the P2P node run on your machine, so the working demo is local by design. |
The problem. Scalping. A ticket for a high-demand event sells at face value and reappears minutes later at five times the price. The loss falls on attendees, and the organizer has no way to stop it once the ticket has left their system — resale happens on platforms they don't control, in private messages, in cash.
The users.
- Organizers issue tickets and want them in the hands of actual attendees rather than bots and resellers.
- Attendees buy a ticket, and sometimes need to sell it on — plans change, and a ticket you can't transfer is worth nothing.
- Peers on the local network — other nodes at the same event — replicate the ledger of what has been issued and sold.
Why not just a database? This is the question the project exists to answer, so it deserves a direct one.
A ticketing company with a database can already stop scalping today: refuse transfers above face value. The reason scalping persists is not technical, it's that the operator of the database is not a neutral party. They earn a fee on every resale, so a higher resale price is more revenue. Nothing stops them raising the cap, making exceptions for partners, or quietly running the secondary market themselves — and no attendee can audit whether they did.
Moving the rule into a smart contract changes who can break it:
- The ceiling is enforced by code, not policy.
listForSalereverts above face value. The organizer deployed that contract but cannot amend it; there is no admin function to lift the cap, and we deliberately did not write one. - Every transfer is publicly verifiable. Anyone can check who owns a ticket and what it sold for, without trusting our word for it.
- Off-contract resale is impossible, not merely forbidden. Raw ERC-721 transfers are blocked, so a scalper cannot agree a cash price privately and hand over the token. Ownership can only move through the priced, capped path.
A database can hold the same rows. What it cannot do is convince a sceptical attendee that the rule was applied to everyone, including the people running it.
Three layers, each with a distinct job. The full diagram is in
docs/architecture.md.
React frontend (Vite, wagmi/viem, MetaMask)
│ reads ticket state, signs transactions
▼
Ethereum layer — Hardhat node + TicketNFT.sol :8545
│ emits TicketMinted / Listed / Unlisted / Transferred
▼
Bridge (network/bridge.py, web3.py)
│ republishes each event as a signed P2P transaction
▼
P2P layer — PyIPv8 overlay + hand-built blockchain :8090 (UDP)
│ mempool → Merkle → proof-of-work → gossip → chain sync
▼
HTTP API (FastAPI) :8080 /tickets, /health, /images
Ethereum layer — ownership and money. TicketNFT.sol is the authoritative
ledger: who holds which ticket, what it is listed at, who gets paid. Payment and
transfer happen atomically inside resaleTransfer.
P2P layer — our own blockchain. network/blockchain/ implements
transactions, ECDSA signing, a Merkle tree, proof-of-work mining, and full chain
validation from scratch, with no Ethereum dependency. TicketChainCommunity
gossips transactions and blocks over IPv8, and nodes reconcile disagreements
with a longest-valid-chain rule.
The bridge connects them. Contract events become signed P2P transactions, so the peer-replicated ledger reflects what actually happened on-chain instead of being a parallel universe. This is the seam where the project became one system rather than two.
Why this shape? The course requires a hand-built blockchain; a real ticket marketplace requires programmable money and a wallet users already trust. Rather than pretend one can do both jobs, we let Ethereum handle value transfer and our own chain handle localized, verifiable propagation, and made the bridge the explicit, documented link between them. The honest limitation of that choice is in §8.
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 20 or 22 LTS | Hardhat warns on newer majors |
| Python | 3.10+ | pyipv8 3.x needs it; macOS ships 3.9 |
| libsodium | any | native dependency of pyipv8; brew install libsodium |
| MetaMask | current | or any injected EIP-1193 wallet |
Installed automatically by ./start_dev.sh and npm install:
- Contracts — Hardhat 2, OpenZeppelin Contracts 5 (ERC-721, Ownable), Chai.
- Frontend — React 19, Vite 8, MUI 9, wagmi 3, viem 2, TanStack Query 5, React Router 7, TypeScript 6.
- Network — pyipv8 3.2.1, cryptography 43, web3 7.16, FastAPI, uvicorn.
git clone https://github.com/Zetchu/ticket-chain.git
cd ticket-chain
./start_dev.shThat stops anything left over from a previous run, then starts the Hardhat
chain, deploys the contract, exports its ABI and address to the frontend, starts
the P2P node with its bridge and HTTP API, and starts Vite. Ctrl+C stops
everything, and ./stop_dev.sh stops a stack that outlived its terminal. The first run also creates network/.venv and
installs the Python dependencies; later runs reinstall only when
requirements.txt changes.
Then add the local network to MetaMask (RPC http://127.0.0.1:8545, chain ID
31337) and import a Hardhat test account.
getting-started.md has the full walkthrough with keys.
For the two-node demo — a second peer whose chain arrives entirely by P2P sync:
./start_demo.shIndividual pieces:
cd contracts && npx hardhat node # chain only
cd contracts && npx hardhat run scripts/deploy.js --network localhost
cd network && .venv/bin/python -u main.py # P2P node + API
cd frontend && npm run dev # UI onlyAs an organizer. Connect account #0 (the deployer) — the Organizer page appears. Enter an event name, date, quantity and a face value in ETH, optionally choose a poster image, then Mint & List. The image is uploaded to the P2P node first and addressed by content hash; one transaction then creates the event, mints the batch, and lists every ticket at the face value you chose. That value is permanent — it is the resale ceiling for those tickets forever — while every new event can be priced freely. The cards appear on Buy Tickets immediately, and the bridge publishes the events to the P2P ledger within a couple of seconds.
As an attendee. Switch to another account, open Buy Tickets, click Buy, and
confirm. resaleTransfer checks the listing, moves the token, and forwards the
ETH to the seller atomically. The ticket moves to My Tickets.
As a reseller. On My Tickets, List for resale, set a price at or below face value, confirm. It appears on Buy Tickets for everyone else, and you can Change price or Unlist at any time.
Inspecting the P2P layer directly:
curl http://127.0.0.1:8080/tickets # ticket offerings this node knows about
curl http://127.0.0.1:8080/health # chain length, mempool size, validityThe price ceiling is enforced at listing time, not at purchase time.
Originally resaleTransfer checked msg.value <= faceValue. That refused
overpayment but had two problems: it allowed underpayment — a buyer could take
any ticket for 0 wei — and it meant an over-priced ticket could still be
advertised. Moving the check into listForSale means a scalper cannot even
publish the offer, and resaleTransfer then requires payment to equal the
listed price exactly, closing both directions.
Listings are opt-in, so no one is forced to sell. Before listings existed,
any buyer could call resaleTransfer on any ticket and take it at face value
from an unwilling holder. A ticket now moves only if its owner has offered it.
All raw ERC-721 transfers are blocked. _update rejects every ownership
change that does not come through resaleTransfer. This deliberately breaks
ERC-721 compliance — transferFrom always reverts — because a compliant token
would let a scalper settle in cash and transfer directly, bypassing the ceiling
entirely. We chose the anti-scalping guarantee over the standard, which is the
kind of trade-off worth stating out loud rather than hiding.
The re-entrancy guard is consumed, not cleared. _inResale authorizes
exactly one transfer and is unset inside _update before onERC721Received
fires, so a malicious buyer contract cannot re-enter from its callback and move
the ticket on while the guard is still open.
The organizer sets the price, and it becomes permanent. Each batch carries its own face value, so a festival pass and a basement gig are priced differently — but that value is also the resale ceiling for the life of the ticket, and there is deliberately no function to change it later. A ceiling an organizer could raise afterwards would not be a ceiling.
The purchase cap applies to the primary sale only. An organizer can limit how many tickets one address may buy from them; the same address can still buy any number on resale. Capping the secondary market would punish the people the ceiling exists to protect, while doing nothing about bulk buying at the source, which is where scalping actually starts.
A ticket stops trading when its event starts. listForSale and
resaleTransfer both refuse once block.timestamp passes the event date. The
frontend greys those tickets out from a ticking clock rather than a value read
during render, so a ticket that expires while the page is open stops offering
itself without waiting for the user to click something.
Event details are stored once per event, not per ticket. Tickets reference an event ID; a 500-seat show stores its name and poster reference once instead of 500 times.
Ticket artwork is content-addressed, and always has a fallback. An uploaded
poster is stored by the SHA-256 of its bytes, so the contract holds a
fixed-length hash rather than a mutable path, and the same image uploaded twice
is stored once. When no image is uploaded, tokenURI returns an SVG generated
on-chain from the token ID — so a ticket renders in a wallet with no external
dependency at all, and is never a blank square.
The frontend enumerates tokens from the chain, not from the P2P feed.
totalMinted() gives the authoritative token range, so a freshly minted ticket
renders immediately rather than waiting for the P2P node to catch up. The feed
supplies supplementary metadata.
The P2P overlay is partitioned by event name. The IPv8 community_id is
derived from --event, so nodes for different events form disjoint overlays.
Combined with UDP-broadcast bootstrapping (no public trackers), discovery stays
inside the local network segment — which is what "localized" means here.
Every P2P message carries a small proof-of-work. An 8-bit search puzzle (~256 hashes) is cheap for a legitimate sender and verified in a single hash by the receiver, so spam is dropped before any signature check. Block mining uses a separate, harder 16-bit target.
Reproduce with cd network && .venv/bin/python benchmark.py --p2p; raw numbers
in network/benchmark_results.json.
| Metric | Result |
|---|---|
| Transaction sign (median, n=200) | 0.32 ms |
| Transaction validate (median, n=200) | 0.33 ms |
| Sign + validate (mean) | 0.79 ms |
| Block mining, 16-bit difficulty (median, n=10) | 389 ms (mean 755 ms, ~66k nonce iterations) |
| Peer discovery latency | 0.87 s |
| Block propagation to a second node | 2.84 s |
Reading these. Signing and validating are sub-millisecond, so throughput is bounded entirely by proof-of-work, not cryptography. Mining time varies widely (7 ms to 2.7 s across ten runs) because nonce search is a geometric process — the median is the honest figure to quote, not the mean. Propagation is comfortably faster than a human refreshing a page, which is why a purchase shows up on a second node before anyone notices.
Contract gas is not benchmarked: the chain is local and gas is free, so any figure would say more about Hardhat than about the design.
We would rather state these than have them found.
- The web app does not depend on the P2P layer. The UI reads ticket state from the contract; the P2P chain mirrors it. Kill the Python node and the app keeps working. The P2P layer is a genuine peer-replicated blockchain and the bridge keeps it faithful, but it is an announcement and replication layer, not the frontend's source of truth.
GET /ticketsreturns one row per event, not per ticket. A mint emits two events, so a token appears twice in the raw feed. The UI de-duplicates by token ID; the endpoint itself does not.- Bridged tickets lose their event name and date. The P2P
Transactionschema has no fields for them, so the feed showsTicket #3and the transaction timestamp. Cards look right only because the frontend reads the real values from the contract. - Uploaded artwork lives on the node that received it. The contract stores only a content hash, and any node can serve the bytes — but a second node that never received the upload cannot, so its cards fall back to generated art. Replicating images across peers is the same problem chain sync solved for blocks, and we did not solve it.
tokenURIpoints at127.0.0.1for uploaded artwork. Fine for a localized demo, meaningless to a wallet on another machine. Tickets minted without an upload carry their SVG inline and render anywhere.- Nothing persists across restarts. Both chains are in-memory or in
Hardhat's ephemeral state;
./start_dev.shstarts from an empty ledger. - A batch is priced once. Every ticket in a
mintAndListcall shares one face value, chosen by the organizer at mint time. It is permanent by design: the batch's resale ceiling can never be raised or lowered afterwards, though each new event sets its own price. (mintTicket, the eventless escape hatch, still uses the 0.05 ETHDEFAULT_FACE_VALUE.) - The P2P chain has no fork-choice beyond longest-valid-chain, and no incentive layer — mining is done by whichever node bridged the event.
- Localized discovery is a design choice with a cost: two nodes on different network segments will never find each other.
Future work, in the order we would do it: de-duplicate the feed; carry event metadata through the bridge; replicate uploaded artwork between peers so a second node can serve it; persist the P2P chain to disk; per-event pricing.
cd contracts && npx hardhat test # 78 tests
cd network && .venv/bin/python -m pytest test_blockchain.py # 68 tests
cd network && .venv/bin/python test_two_nodes.py # live two-node syncContracts — 78 tests. Minting and organizer-only access control; the event
registry (details stored per batch, separate batches on separate events,
metadata surviving a resale); listing and cancellation, including refusal above
face value, from non-owners, and on locked tickets; purchase, covering exact
payment, over- and underpayment, unlisted and cancelled tickets, buying your own
ticket, and repeat resale; a mock contract that rejects ETH, to reach the
"payment to seller failed" branch no EOA test can; and the transfer lockdown —
transferFrom, safeTransferFrom, approved addresses and operators are all
blocked; and token metadata — that tokenURI decodes to parseable JSON, points
at uploaded artwork when there is any, falls back to generated SVG when there is
not, and escapes an event name containing quotes and angle brackets rather than
emitting a document no wallet can read; per-event pricing, including rejection
of a zero face value and the ceiling applying per ticket rather than globally;
the per-address primary-sale cap, including that resale is exempt from it; and
the event-expiry guard on both listing and purchase.
Blockchain core — 68 tests. Signature verification and tamper rejection; the price-ceiling rule; Merkle roots for even and odd leaf counts plus inclusion proofs; proof-of-work mining and verification; whole-chain validation against tampered transactions, broken links and forged proofs; the per-message search puzzle; the longest-valid-chain rule, including rejection of shorter, invalid, and foreign-genesis chains; the HTTP API; and artwork upload — content addressing, deduplication, rejection of non-images, empty and oversized files, and malformed hashes.
Integration. test_two_nodes.py starts two IPv8 nodes and asserts mutual
discovery and chain convergence. docs/demo-scenarios.md is a manual runbook
for the sunny- and rainy-day paths, with the captured output of each.
What is not covered: no frontend unit or end-to-end tests — the UI was
verified manually and with a headless-browser render check. The Vercel
deployment runs tsc -b && vite build on every push, so a type error cannot
reach main unnoticed; ESLint is run locally with npm run lint.
| Area | |
|---|---|
| David | Project setup, TicketNFT.sol, resale listings, frontend |
| Haythem | P2P network layer, contract-to-P2P bridge |
| Khalil | Chain sync and the two-node demo, benchmarking, demo runbook, progress reports |
| Mahmoud | Primary market (organizer minting), chain-first ticket enumeration |
Commit history on main reflects the split; each feature landed through a pull
request from its own branch.