DEV Community

Cover image for I Built a Solana Waitlist for a Fake $10k Bounty - Here's the Open Source Code
Sivaram
Sivaram

Posted on

I Built a Solana Waitlist for a Fake $10k Bounty - Here's the Open Source Code

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:

  1. Connect X (Twitter) — OAuth 2.0 login, captures @handle and display name
  2. Connect Wallet — Phantom, Solflare, or any Solana wallet
  3. 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
Enter fullscreen mode Exit fullscreen mode

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(),
});
Enter fullscreen mode Exit fullscreen mode

Key decisions:

  • Store twitterHandle with original casing for display
  • walletSignature stores the BS58-encoded signature for verification
  • referredBy uses 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()}`
    );
}
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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 } });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3001 and you're running.


Rebranding in 45 Minutes

The repo includes a PLAYBOOK.md with everything you need to rebrand:

  1. Logo: Replace components/tap-logo.tsx
  2. Colors: Find/replace #15F228 with your brand color
  3. Copy: Update text in page.tsx and waitlist-flow.tsx
  4. Metadata: Update layout.tsx
  5. Assets: Swap favicon and OG image in public/

The PLAYBOOK also covers UI library selection, font choices, responsive patterns, and deployment.


Lessons Learned

  1. Verify bounties before building — Check if it's engagement farming
  2. Store both username AND display name — Twitter OAuth returns display name, not @handle
  3. Case-insensitive referral matching — Users share links with random casing
  4. Persist state through OAuth — localStorage survives redirects
  5. 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)