Last week I saw a tweet: "Need a waitlist system in 3 hours. Paying $10k."
I dropped everything and built it. Twitter OAuth, Solana wallet verification, referral system with leaderboard. The works.
The bounty was fake. Engagement farming.
But I kept the code. And now I'm open sourcing it.
GitHub: tapdotfun-waitlist-referral-system
Live Link: tdotf
What It Does
A 3-step waitlist onboarding flow:
- Connect X (Twitter) — OAuth 2.0 login, captures @handle and display name
- Connect Wallet — Phantom, Solflare, or any Solana wallet
- Sign Message — Proves wallet ownership without gas fees
Plus a referral system:
- Case-insensitive referral links (
?ref=username) - Automatic referral count tracking
- Real-time leaderboard showing top referrers
The Tech Stack
| Layer | Technology |
|---|---|
| Frontend | Next.js 16 + React 19 |
| Backend API | oRPC (end-to-end type-safe) |
| Database | PostgreSQL + Drizzle ORM |
| Authentication | Better Auth (Twitter OAuth) |
| Wallet | Jupiter Unified Wallet Kit |
| UI | shadcn/ui + TailwindCSS 4 |
| Monorepo | Turborepo + Bun |
Why this stack?
- oRPC gives you end-to-end type safety without the tRPC complexity
- Better Auth handles OAuth properly (not NextAuth — different package entirely)
- Drizzle is TypeScript-first and doesn't fight you
- Jupiter's Unified Wallet Kit supports all major Solana wallets out of the box
Project Structure
tapdotfun/
├── apps/
│ └── web/ # Next.js fullstack app
│ ├── src/
│ │ ├── app/ # App router pages
│ │ ├── components/ # React components
│ │ └── utils/ # oRPC client
│
├── packages/
│ ├── api/ # oRPC routers & procedures
│ ├── auth/ # Better Auth configuration
│ ├── db/ # Drizzle schema & migrations
│ └── env/ # Environment validation
Monorepo structure keeps things clean. Auth logic in one package, database in another, API separate. Easy to reason about.
The Database Schema
// packages/db/src/schema/waitlist.ts
export const waitlist = pgTable("waitlist", {
id: text("id").primaryKey(), // nanoid
userId: text("user_id").notNull(),
twitterHandle: text("twitter_handle").notNull(),
twitterName: text("twitter_name"),
twitterId: text("twitter_id").notNull(),
walletAddress: text("wallet_address"),
walletSignature: text("wallet_signature"), // bs58 encoded
referredBy: text("referred_by"),
referralCount: integer("referral_count").default(0).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
Key decisions:
- Store
twitterHandlewith original casing for display -
walletSignaturestores the BS58-encoded signature for verification -
referredByuses case-insensitive matching (more on this below)
The Referral System
This caught me off guard initially. Users share links with different casing:
?ref=SivaramPg?ref=sivarampg?ref=SIVARAMPG
All should work. Here's how:
// Store with original casing
twitterHandle: session.user.name; // "SivaramPg"
// Match case-insensitively
if (input.referredBy) {
await db
.update(waitlist)
.set({ referralCount: sql`${waitlist.referralCount} + 1` })
.where(
sql`lower(${waitlist.twitterHandle}) = ${input.referredBy.toLowerCase()}`
);
}
The referral param is captured by a client component and stored in localStorage — this survives the OAuth redirect.
Wallet Verification Without Gas
No one wants to pay gas just to join a waitlist. Message signing proves ownership without transactions:
const message = `Verify wallet for tap.fun waitlist: ${Date.now()}`;
const encodedMessage = new TextEncoder().encode(message);
const signature = await signMessage(encodedMessage);
const signatureBase58 = bs58.encode(signature);
// Store both address and signature
await client.updateWallet({
walletAddress: publicKey.toString(),
walletSignature: signatureBase58,
});
The timestamp prevents replay attacks. The signature proves they control the wallet. Zero cost.
API Endpoints
All type-safe via oRPC:
| Endpoint | Auth | Purpose |
|---|---|---|
healthCheck |
Public | Server health |
getLeaderboard |
Public | Top 10 referrers |
joinWaitlist |
Protected | Create entry, credit referrer |
updateWallet |
Protected | Store wallet + signature |
checkWaitlist |
Protected | Get user's status |
getReferralCount |
Protected | Get referral stats |
Protected routes use a middleware that checks the Better Auth session:
const protectedProcedure = publicProcedure.use(async ({ context, next }) => {
if (!context.session) {
throw new ORPCError("UNAUTHORIZED");
}
return next({ context: { session: context.session } });
});
Getting Started
# Clone
git clone https://github.com/SivaramPg/tapdotfun-waitlist-referral-system.git
cd tapdotfun-waitlist-referral-system
# Install
bun install
# Set up env
cp apps/web/.env.example apps/web/.env
# Edit with your DATABASE_URL, Twitter OAuth keys, etc.
# Database
bun run db:push
# Run
bun run dev
Open http://localhost:3001 and you're running.
Rebranding in 45 Minutes
The repo includes a PLAYBOOK.md with everything you need to rebrand:
-
Logo: Replace
components/tap-logo.tsx -
Colors: Find/replace
#15F228with your brand color -
Copy: Update text in
page.tsxandwaitlist-flow.tsx -
Metadata: Update
layout.tsx -
Assets: Swap favicon and OG image in
public/
The PLAYBOOK also covers UI library selection, font choices, responsive patterns, and deployment.
Lessons Learned
- Verify bounties before building — Check if it's engagement farming
- Store both username AND display name — Twitter OAuth returns display name, not @handle
- Case-insensitive referral matching — Users share links with random casing
- Persist state through OAuth — localStorage survives redirects
- No gas for verification — Message signing proves ownership free
What's Next?
The repo is MIT licensed. Use it however you want.
If you're building a Solana project and need a waitlist, clone it. If you want to improve it, PRs are welcome.
I'm available for freelance Solana/Web3 projects. DMs open on Twitter: @SivaramPg
GitHub: tapdotfun-waitlist-referral-system
The fake bounty wasn't a total loss. At least now someone else can ship faster.

Top comments (0)