Pull blocks. Master crypto. Own your knowledge.
PullChain is a gamified Web3 education app on Base Mainnet. Players pull blocks from a Jenga-style tower, learn real crypto topics, pass quizzes, and mint soulbound ERC-721 certificates as non-transferable on-chain proof of knowledge.
- Live: pullchain.fun
- Contract:
0x5b9e85bbee0ba7abc3f69833bf97ea8799e0e3b3(Base) - Builder: Mojeeb Titilayo — BlindspotLab
This repository is production software. The certificate contract is not formally audited unless separate audit evidence is published.
Defined in lib/game-config.ts:
| Mode | Tower size | Clear / certificate threshold |
|---|---|---|
| Easy (learning) | 36 | 16 |
| Medium (learning) | 36 | 24 |
| Hard (learning) | 36 | 36 |
| Elite (slang) | 18 | 18 (leaderboard only, no cert) |
| Rule | Value |
|---|---|
| Quiz questions per block | 4 |
| Quiz pass (progress / streak) | ≥ 3/4 correct |
| Life loss | Only on 0/4 correct |
| Starting lives | 3 |
| Starting stability | 100 |
| Stability damage by correct count | 22, 14, 8, 3, 0 |
| Last-in-row dangerous bonus | +5 stability damage |
| Elite access | Minted Easy certificate (DB record and/or on-chain hasCertificate) |
Elite attempts use mode: elite and never write learning Progress rows.
- Privy verifies access tokens server-side (
requireAuthenticatedUser/verifiedPrivyIdentity). /api/auth/syncderives identity only from the verified Privy token — browser identity fields are not trusted.- Session, progress, certificate, and leaderboard mutations require ownership of the authenticated user.
- Quiz scoring is server-authoritative: selected option text is re-checked against the word bank. Client
correctflags and scores are not trusted. - Session lives, stability, score, completion, and failure are computed on the server in
applyAttemptToSession. - There is no free-form
PATCHfor game stats — state advances viaPOST /api/session/[id]/attempt. - Progress cannot be written directly (
POST /api/progress→ 405). - Certificate mint recording verifies Base receipt, sender, contract address,
CertificateMintedevent, difficulty, and token ID. - Duplicate certs per user/difficulty and duplicate
tx_hashare rejected. - API errors never return raw stack traces, emails, wallets, or secrets.
- Quiz option presentation is still client-generated from a deterministic seed; integrity relies on matching selected text to the word bank, not on hiding the answer key from the browser.
- Elite gate UI primarily checks the certificates API; server session create also enforces Easy cert (DB and on-chain when a wallet is present).
- Contract is open mint for any wallet calling
mint— game eligibility is enforced in the app DB layer when recording mints, not by an on-chain allowlist. - No formal smart-contract audit claimed.
On POST /api/session:
- A cryptographic seed and ordered
topic_idsare stored on the session. - Topic order uses seeded Fisher–Yates (
lib/seeded-random.ts). - If an active session exists for the same user + difficulty + mode and
restartis not set, it is returned (resumed: true) — React Strict Mode cannot create duplicates. restart: truefails the prior active session and creates a new seed/topic order (replay).
Resume restores:
- seed, topic order, pulled
words_seen, score, lives, stability, status, quiz attempts
| Method | Path | Notes |
|---|---|---|
POST |
/api/auth/sync |
Create/update user from verified Privy identity |
PATCH |
/api/profile/username |
Zod username validation; conflict → 409 |
POST |
/api/session |
Create or resume session (difficulty, mode, restart?) |
GET |
/api/session/active |
Active session for user (optional difficulty/mode filters) |
GET |
/api/session/[id] |
Owned session + attempts |
POST |
/api/session/[id]/attempt |
Authoritative quiz attempt + state transition |
GET |
/api/progress |
Owned progress (read-only) |
POST |
/api/progress |
Disabled (405) |
GET |
/api/certificate/eligibility?difficulty= |
Authoritative eligibility |
GET |
/api/certificate/mint |
List owned certificates |
POST |
/api/certificate/mint |
Record verified on-chain mint |
GET/POST |
/api/leaderboard |
Read public ranks; write from owned completed session only |
GET |
/api/nft/[tokenId] |
Token metadata |
GET |
/api/nft/image/[tokenId] |
Dynamic cert image |
POST |
/api/quiz/submit |
Deprecated (410) — use session attempt |
- User —
privy_id, optional wallet/email, uniqueusername - Session — difficulty,
mode(learning|elite),seed,topic_ids[], lives, stability, score, streaks,words_seen[], status - QuizAttempt — owned via session; structured answers JSON; server score/passed
- Progress — learning only; unique
(user_id, topic_id, difficulty) - Certificate — unique
(user_id, difficulty); uniquetx_hashwhen set - LeaderboardEntry — difficulty + mode; score/time from completed sessions
# Review SQL under prisma/migrations/, then on the target DB:
pnpm exec prisma migrate deployDo not use prisma db push against production.
Additive migrations:
20260803000000_add_session_game_mode—sessions.mode20260803120000_session_seed_topic_ids—sessions.seed,sessions.topic_ids, uniquecertificates.tx_hash
Soulbound ERC-721 on Base:
| Behavior | Implementation |
|---|---|
| One cert per wallet per difficulty | hasCertificate[wallet][diff]; AlreadyMinted() |
| Invalid difficulty | difficultyLevel > 2 → InvalidDifficulty() |
| Non-transferable | All transfer/approve paths revert Soulbound() |
| Token lookup | tokenIdOf, getCertificate, ownerOf |
| Metadata | tokenURI = base URI + tokenId + .json; owner may setMetadataURI |
| Mint event | CertificateMinted(holder, tokenId, difficulty, score, issuedAt) |
Difficulty encoding: 0 = Easy, 1 = Medium, 2 = Hard.
Do not redeploy or upgrade this contract from app changes. App verification lives in lib/chain.ts.
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL |
yes | Postgres (Prisma) |
NEXT_PUBLIC_PRIVY_APP_ID |
yes | Privy client |
PRIVY_APP_SECRET |
yes | Privy server verification |
NEXT_PUBLIC_CONTRACT_ADDRESS |
yes | PullChainCert address |
NEXT_PUBLIC_BASE_RPC |
optional | Base RPC (default public) |
RESEND_API_KEY |
optional | Welcome email (lazy init; missing key must not break build) |
EMAIL_FROM |
optional | Resend from-address |
Never commit secrets. CI may inject build-time public/service placeholders only.
pnpm install
# configure .env
pnpm exec prisma migrate deploy
pnpm devpnpm lint
pnpm typecheck
pnpm test
pnpm build- Node 22 and pnpm 11 match GitHub Actions.
- Tests: Vitest unit suite under
lib/*.test.ts(validation, shuffle, resume state, quiz re-score, thresholds).
| Path | Role |
|---|---|
/ |
Landing; unauthenticated users go to login before learning |
/login |
Privy auth |
/dashboard |
Stats / resume entry |
/learn |
Difficulty selector |
/learn/[difficulty] |
Learning Jenga (easy | medium | hard) |
/learn/elite |
Elite slang mode (Easy cert gated) |
/certificate, /certificate/mint |
View / mint certs |
/leaderboard |
Rankings |
/about, /privacy, /terms |
Marketing / legal |
| File | Count (approx) | Use |
|---|---|---|
lib/words/easy.ts |
500 | Easy learning |
lib/words/medium.ts |
500 | Medium learning |
lib/words/hard.ts |
500 | Hard learning |
lib/words/slang.ts |
150 | Elite only |
- Next.js (App Router) + TypeScript + Framer Motion
- Privy + wagmi/viem (Base)
- Prisma 7 + PostgreSQL
- Zod trust boundaries
- Resend (optional email)
- Vitest + ESLint + GitHub Actions CI
- Vercel deployment
Mojeeb Titilayo — Full-stack Web3 and AI Product Engineer. Founder of BlindspotLab. Designs and ships production applications across smart contracts, embedded wallets, authentication, backend systems and user-facing products.
- X: @MojeebMotion
- Portfolio: mojeeb.xyz
- Studio: blindspotlab.xyz