Onchain roast battles on Monad Testnet. Create an arena, stake MON, drop your best burn, and let the crowd vote. Losing voters fund the winners β prediction market meets roast comedy.
Live at: https://monad-rage.vercel.app/ Β· Contract: 0xff6E651c905cD7a5aE4b8d404173c07dE254D322 on Monad Testnet
DEPLOYED CONTRACT ADDRESS : 0xff6E651c905cD7a5aE4b8d404173c07dE254D322
| Phase | Duration | What happens |
|---|---|---|
| OPEN | creator-set, 1 minβ24 h (default 3 min) | Roasters pay roastStake to join & submit their roast |
| VOTING | creator-set, 1 minβ24 h (default 4 min) | Anyone pays voteStake to vote for their favourite roaster |
| SETTLED | β | Winners split the roaster pool; voters who backed a winner split the voter pool |
| CANCELLED | β | Triggered if < 2 roasters joined or nobody voted β full refunds issued |
The prediction-market twist: Losing voters' stakes flow to the winners' voters. Back the wrong roaster and your stake funds those who backed the right one.
roast-arena/
βββ src/
β βββ RoastArena.sol # Solidity smart contract
βββ script/
β βββ Deploy.s.sol # Foundry deployment script
β βββ Simulate.s.sol # Full end-to-end simulation (7 scenarios)
βββ test/
β βββ RoastArena.t.sol # Forge test suite (~765 lines)
βββ backend/ # Node.js / Express API
β βββ index.js # Routes & middleware
β βββ db.js # Schema + Turso helpers
β βββ listener.js # On-chain event listener
β βββ storage.js # File upload handler (local / cloud-switchable)
βββ frontend/ # Next.js 16 app
β βββ app/
β β βββ page.tsx # Home β arena listing + create form
β β βββ arena/[id]/page.tsx # Arena detail β join / vote / claim
β β βββ profile/[address]/page.tsx
β βββ components/Navbar.tsx
β βββ lib/
β βββ contract.ts # ABI, addresses, chain config, enums
β βββ useWallet.ts # Wallet hook (ethers.js)
β βββ api.ts # Backend API client
β βββ useCountdown.ts
βββ foundry.toml
src/RoastArena.sol β Solidity ^0.8.24, deployed with Foundry
OPEN (0) ββ3 minβββΆ VOTING (1) ββ4 minβββΆ SETTLED (2)
ββββΆ CANCELLED (3)
State is computed lazily from block.timestamp against the stored deadlines β no keeper needed.
| Function | Who can call | Description |
|---|---|---|
createRoast(roastStake, voteStake) |
Anyone | Creates arena with default 3/4-minute windows |
createRoast(roastStake, voteStake, openDuration, voteDuration) |
Anyone | Creates arena with custom windows (1 minβ1 day each) |
joinRoast(roastId) |
Anyone (OPEN phase) | Joins as roaster, pays exact roastStake |
vote(roastId, candidate) |
Anyone (VOTING phase) | Casts one vote, pays voteStake; no self-votes |
settle(roastId) |
Roasters or voters; anyone 1 h after voting ends | Finalises arena, determines winners |
claimRoasterReward(roastId) |
Winners | Claim equal share of roaster pool |
claimVoterReward(roastId) |
Voters who backed a winner | Claim share of voter pool |
claimRefund(roastId) |
Any participant | Refund from CANCELLED arena |
roasterShare = roasterPool / numWinners
voterPool = sum of all voteStakes
voterShare = voterPool / winnerVoterCount
= voterPool / (numWinners Γ highestVotes)
Example: 3 voters stake 0.5 ETH each β pool = 1.5 ETH. Alice wins with 2 votes. Two Alice-voters each receive 1.5 / 2 = 0.75 ETH (1.5Γ their stake). Bob's voter loses.
- Pull-payment pattern (reentrancy safe) on all claim functions
- Payouts via
call(full gas) β smart-contract wallets can claim - Custom errors for gas efficiency (
RoastNotFound,AlreadyClaimed,NotInWindow, β¦) exists()modifier guards all view functions- Integer division β dust stays in contract
Node.js + Express 5, Turso (SQLite) database
| Method | Path | Description |
|---|---|---|
GET |
/health |
Liveness check |
GET |
/roasts?limit=20 |
Recent arenas, newest first |
GET |
/roast/:id |
Single arena metadata |
GET |
/roast/:id/content |
All roast submissions (with profiles) |
POST |
/roast/:id/content |
Submit roast text (max 500 chars, one per wallet) |
GET |
/roast/:id/challenge |
What's being roasted (title, description, media) |
POST |
/roast/:id/challenge |
Set challenge content |
POST |
/upload |
Upload image (JPEG/PNG/GIF/WebP, max 10 MB) |
GET |
/profile/:address |
User profile |
POST |
/profile |
Upsert profile (username, avatar, bio) |
GET |
/profile/:address/roasts |
Roast history for an address |
profiles (address PK, username, avatar_url, bio, updated_at)
roast_index (roast_id PK, creator, state, open_until, vote_until, pools, β¦)
participant_index (roast_id, address β composite PK)
roast_content (id, roast_id, author UNIQUE per roast, content, created_at)
challenge_content (roast_id PK, creator, title, description, media_url, created_at)
listener_state (key PK, value β persists lastPolledBlock across restarts)Polls eth_getLogs every 5 s in 100-block chunks (Monad RPC limit).
Persists lastPolledBlock to DB so restarts resume from where they left off.
Cold-start lookback: last 200 blocks.
Events handled: RoastCreated Β· ParticipantJoined Β· VoteCast Β· RoastSettled Β· RoastCancelled
Next.js 16.1.6 Β· React 19 Β· ethers.js v6 Β· Tailwind CSS 4
| Route | Description |
|---|---|
/ |
Arena list with live countdown (Roast / Vote phase labels), create-arena form |
/arena/[id] |
Join, submit roast text, vote, settle, claim rewards; live vote bars |
/profile/[address] |
Edit username/bio/avatar; view roast history |
- Challenge title (required, max 100 chars) β what everyone is roasting
- Description (optional, max 500 chars)
- Media: Text only or Image upload (stored via backend, swappable to cloud)
- Roaster stake & vote stake amounts
- Transaction fires on-chain β off-chain content is submitted after confirmation
- Auto-connects if wallet already approved the site
- Detects wrong network and offers one-click switch
- Adds Monad Testnet to MetaMask if not already configured
- Reacts to account and chain changes
Promise.allSettledfor all per-user view calls β one node hiccup doesn't break the whole pollgetVoteCountswrapped in its own try-catch (transientCALL_EXCEPTIONfrom QuickNode load balancing on fresh arenas self-heals on the next 4 s poll)- Block-timestamp offset correction β aligns chain timestamps to real-world clock for accurate countdowns
# Build
forge build
# Run tests
forge test -vv
# Run full simulation
forge script script/Simulate.s.sol --fork-url https://testnet-rpc.monad.xyz -vv
# Deploy to Monad Testnet
forge script script/Deploy.s.sol \
--rpc-url https://testnet-rpc.monad.xyz \
--private-key $PRIVATE_KEY \
--broadcastcd backend
cp .env.example .env # fill in your values
npm install
node index.js
# β http://localhost:3001backend/.env
CONTRACT_ADDRESS=0xff6E651c905cD7a5aE4b8d404173c07dE254D322
MONAD_RPC=https://testnet-rpc.monad.xyz
PORT=3001
TURSO_DATABASE_URL=libsql://<your-db>.turso.io
TURSO_AUTH_TOKEN=<your-token>
# Optional: persistent image hosting (local ./uploads fallback if unset)
CLOUDINARY_URL=cloudinary://<api_key>:<api_secret>@<cloud_name>
# Optional: comma-separated CORS allowlist (all origins allowed if unset; no trailing slashes)
ALLOWED_ORIGINS=https://monad-rage.vercel.app,http://localhost:3000
cd frontend
# create .env.local with the values below
npm install
npm run dev
# β http://localhost:3000frontend/.env.local
NEXT_PUBLIC_CONTRACT_ADDRESS=0xff6E651c905cD7a5aE4b8d404173c07dE254D322
NEXT_PUBLIC_MONAD_RPC=https://testnet-rpc.monad.xyz
NEXT_PUBLIC_BACKEND_URL=http://localhost:3001
NEXT_PUBLIC_CHAIN_ID=10143
| Layer | Hosted on |
|---|---|
| Smart contract | Monad Testnet β 0xff6E651c905cD7a5aE4b8d404173c07dE254D322 |
| Backend API | Render (Node web service) β https://monad-rage.onrender.com |
| Frontend | Vercel β https://monad-rage.vercel.app |
| Database | Turso (edge SQLite) |
| File uploads | Cloudinary via CLOUDINARY_URL (local uploads/ fallback β ephemeral on Render) |
Render (backend): root directory backend, build npm install, start npm start, plus the backend/.env variables above (Render sets PORT itself).
Vercel (frontend): root directory frontend, default Next.js build settings, with:
NEXT_PUBLIC_CONTRACT_ADDRESS=0xff6E651c905cD7a5aE4b8d404173c07dE254D322
NEXT_PUBLIC_BACKEND_URL=https://monad-rage.onrender.com
| Layer | Technology |
|---|---|
| Smart contract | Solidity ^0.8.24, Foundry |
| Blockchain | Monad Testnet (chain ID 10143) |
| Backend | Node.js, Express 5, ethers.js v6 |
| Database | Turso (libSQL / SQLite) |
| Frontend | Next.js 16, React 19, TypeScript |
| Styling | Tailwind CSS 4 |
| Web3 client | ethers.js v6, MetaMask |
| File storage | Cloudinary (local fallback) |
script/Simulate.s.sol covers seven end-to-end flows against a live fork:
- Happy path β Alice wins, winner voters rewarded
- Only creator β CANCELLED, refunded
- No votes β CANCELLED, roasters refunded
- Three-way tie β pools split equally among all three
- Prediction market β losing voter stakes redistributed to Alice's backers
- Roaster also votes β roaster pays extra stake to vote
- Revert edge cases β validates all custom errors fire correctly