Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions lib/airdrop/verify-wallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Shared SIWE wallet ownership verification (#883)
*
* Extracts address from a SIWE-style message and verifies the signature.
*/

import { verifyMessage } from "viem";

/**
* Verify wallet ownership via signed message.
* Returns the verified lowercase address, or null if verification fails.
*/
export async function verifyWalletOwnership(
message: string,
signature: `0x${string}`,
): Promise<string | null> {
// Parse address from SIWE message
const addressMatch =
message.match(/^(0x[a-fA-F0-9]{40})/m) ??
message.match(/wants you to sign in with your Ethereum account:\n(0x[a-fA-F0-9]{40})/);

if (!addressMatch) return null;

const address = addressMatch[1].toLowerCase();

try {
const valid = await verifyMessage({
address: address as `0x${string}`,
message,
signature,
});
return valid ? address : null;
} catch {
return null;
}
}
3,200 changes: 1,663 additions & 1,537 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@supabase/supabase-js": "^2.99.1",
"@tanstack/react-query": "^5.90.21",
"@vercel/analytics": "^2.0.1",
"nanoid": "^5.1.9",
"next": "16.1.6",
"ox": "^0.14.8",
"react": "19.2.3",
Expand Down
24 changes: 24 additions & 0 deletions src/app/airdrop/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { ReferralInput } from "../../components/ReferralInput";

export const metadata: Metadata = {
title: "PLOT 10x Airdrop | PlotLink",
description: "Earn PL points through trading, writing, and referrals.",
};

export default function AirdropPage() {
return (
<main className="mx-auto max-w-xl px-4 py-8">
<h1 className="text-foreground text-xl font-bold mb-2">PLOT 10x Airdrop</h1>
<p className="text-muted text-sm mb-6">
Earn PL points through trading, writing, rating, and referrals.
Campaign details coming soon.
</p>

<section className="mb-6">
<h2 className="text-foreground text-sm font-bold mb-3">Referral</h2>
<ReferralInput />
</section>
</main>
);
}
25 changes: 4 additions & 21 deletions src/app/api/airdrop/checkin/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
*/

import { NextResponse } from "next/server";
import { verifyMessage } from "viem";
import { createServerClient } from "../../../../../lib/supabase";
import { AIRDROP_CONFIG } from "../../../../../lib/airdrop/config";
import { getStreakBoost, dropOneTier, getNextTier } from "../../../../../lib/airdrop/streak";
import { verifyWalletOwnership } from "../../../../../lib/airdrop/verify-wallet";

export async function POST(req: Request) {
const supabase = createServerClient();
Expand All @@ -30,26 +30,9 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}

// Parse SIWE message to extract address
const addressMatch = message.match(/^(0x[a-fA-F0-9]{40})/m) ??
message.match(/wants you to sign in with your Ethereum account:\n(0x[a-fA-F0-9]{40})/);
if (!addressMatch) {
return NextResponse.json({ error: "Invalid SIWE message" }, { status: 400 });
}
const claimedAddress = addressMatch[1].toLowerCase();

// Verify signature
let valid: boolean;
try {
valid = await verifyMessage({
address: claimedAddress as `0x${string}`,
message,
signature,
});
} catch {
valid = false;
}
if (!valid) {
// Verify wallet ownership via SIWE signature
const claimedAddress = await verifyWalletOwnership(message, signature);
if (!claimedAddress) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}

Expand Down
129 changes: 129 additions & 0 deletions src/app/api/airdrop/referral-code/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Referral code endpoint (#883)
*
* GET /api/airdrop/referral-code?address=0x... — fetch existing code (no creation)
* POST /api/airdrop/referral-code — generate or retrieve code
* Body: { message: string, signature: string, useFarcasterUsername?: boolean }
*/

import { NextResponse, type NextRequest } from "next/server";
import { nanoid } from "nanoid";
import { createServerClient } from "../../../../../lib/supabase";
import { verifyWalletOwnership } from "../../../../../lib/airdrop/verify-wallet";

export async function GET(req: NextRequest) {
const supabase = createServerClient();
if (!supabase) {
return NextResponse.json({ error: "Supabase not configured" }, { status: 500 });
}

const address = req.nextUrl.searchParams.get("address")?.toLowerCase();
if (!address) {
return NextResponse.json({ error: "Missing address param" }, { status: 400 });
}

const { data } = await supabase
.from("pl_referral_codes")
.select("code, is_farcaster_username")
.eq("address", address)
.single();

if (!data) {
return NextResponse.json({ code: null });
}

return NextResponse.json({ code: data.code, is_farcaster_username: data.is_farcaster_username });
}

export async function POST(req: Request) {
const supabase = createServerClient();
if (!supabase) {
return NextResponse.json({ error: "Supabase not configured" }, { status: 500 });
}

let message: string;
let signature: `0x${string}`;
let useFarcasterUsername: boolean;
try {
const body = await req.json();
message = body.message;
signature = body.signature;
useFarcasterUsername = body.useFarcasterUsername === true;
if (!message || !signature) throw new Error("missing fields");
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}

const address = await verifyWalletOwnership(message, signature);
if (!address) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}

// Check for existing code (immutable once set)
const { data: existing } = await supabase
.from("pl_referral_codes")
.select("code, is_farcaster_username")
.eq("address", address)
.single();

if (existing) {
return NextResponse.json({ code: existing.code, is_farcaster_username: existing.is_farcaster_username });
}

let code: string;
let isFarcasterUsername = false;

if (useFarcasterUsername) {
// Look up Farcaster username via users table
const { data: user } = await supabase
.from("users")
.select("username, fid")
.or(`primary_address.ilike.${address},custody_address.ilike.${address}`)
.not("fid", "is", null)
.single();

if (!user?.username) {
return NextResponse.json({ error: "No Farcaster account found for this wallet" }, { status: 400 });
}

// Check if username is already taken as a code by another wallet
const { data: taken } = await supabase
.from("pl_referral_codes")
.select("address")
.eq("code", user.username)
.single();

if (taken) {
return NextResponse.json({ error: "Farcaster username already in use as referral code" }, { status: 409 });
}

code = user.username;
isFarcasterUsername = true;
} else {
code = nanoid(8);
}

const { error } = await supabase.from("pl_referral_codes").insert({
address,
code,
is_farcaster_username: isFarcasterUsername,
});

if (error) {
// Handle race condition — another request may have inserted first
if (error.code === "23505") {
const { data: retry } = await supabase
.from("pl_referral_codes")
.select("code, is_farcaster_username")
.eq("address", address)
.single();
if (retry) {
return NextResponse.json({ code: retry.code, is_farcaster_username: retry.is_farcaster_username });
}
}
console.error("[referral-code] Insert failed:", error.message);
return NextResponse.json({ error: "Failed to generate code" }, { status: 500 });
}

return NextResponse.json({ code, is_farcaster_username: isFarcasterUsername });
}
120 changes: 120 additions & 0 deletions src/app/api/airdrop/register-referral/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Referral registration endpoint (#883)
*
* GET /api/airdrop/register-referral?address=0x... — check existing referrer
* POST /api/airdrop/register-referral — register referral (SIWE)
* Body: { message: string, signature: string, referralCode: string }
*
* Records a referral relationship. One referrer per wallet, first-come.
* No retroactive points — only applies to future buy-points.
*/

import { NextResponse, type NextRequest } from "next/server";
import { createServerClient } from "../../../../../lib/supabase";
import { verifyWalletOwnership } from "../../../../../lib/airdrop/verify-wallet";

export async function GET(req: NextRequest) {
const supabase = createServerClient();
if (!supabase) {
return NextResponse.json({ error: "Supabase not configured" }, { status: 500 });
}

const address = req.nextUrl.searchParams.get("address")?.toLowerCase();
if (!address) {
return NextResponse.json({ error: "Missing address param" }, { status: 400 });
}

const { data } = await supabase
.from("pl_referrals")
.select("referrer_address, referral_code")
.eq("referred_address", address)
.single();

if (!data) {
return NextResponse.json({ referrer: null });
}

// Look up referrer's display name from referral code table
const { data: codeData } = await supabase
.from("pl_referral_codes")
.select("code, is_farcaster_username")
.eq("address", data.referrer_address)
.single();

const displayName = codeData?.is_farcaster_username
? `@${codeData.code}`
: data.referral_code;

return NextResponse.json({
referrer: data.referrer_address,
displayName,
});
}

export async function POST(req: Request) {
const supabase = createServerClient();
if (!supabase) {
return NextResponse.json({ error: "Supabase not configured" }, { status: 500 });
}

let message: string;
let signature: `0x${string}`;
let referralCode: string;
try {
const body = await req.json();
message = body.message;
signature = body.signature;
referralCode = body.referralCode?.trim();
if (!message || !signature || !referralCode) throw new Error("missing fields");
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}

const address = await verifyWalletOwnership(message, signature);
if (!address) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}

// Check if already referred
const { data: existing } = await supabase
.from("pl_referrals")
.select("referrer_address")
.eq("referred_address", address)
.single();

if (existing) {
return NextResponse.json({ error: "Already referred", referrer: existing.referrer_address }, { status: 409 });
}

// Look up referrer by code
const { data: referrer } = await supabase
.from("pl_referral_codes")
.select("address")
.eq("code", referralCode)
.single();

if (!referrer) {
return NextResponse.json({ error: "Invalid referral code" }, { status: 404 });
}

// Prevent self-referral
if (referrer.address.toLowerCase() === address) {
return NextResponse.json({ error: "Cannot refer yourself" }, { status: 400 });
}

const { error } = await supabase.from("pl_referrals").insert({
referrer_address: referrer.address.toLowerCase(),
referred_address: address,
referral_code: referralCode,
});

if (error) {
if (error.code === "23505") {
return NextResponse.json({ error: "Already referred" }, { status: 409 });
}
console.error("[register-referral] Insert failed:", error.message);
return NextResponse.json({ error: "Registration failed" }, { status: 500 });
}

return NextResponse.json({ success: true, referrer: referrer.address.toLowerCase() });
}
11 changes: 10 additions & 1 deletion src/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { WagmiProvider } from "wagmi";
import { RainbowKitProvider, type Theme } from "@rainbow-me/rainbowkit";
import { config } from "../../lib/wagmi";
import { useState } from "react";
import { useState, Suspense } from "react";
import { useReferralCapture } from "../hooks/useReferralCapture";

import "@rainbow-me/rainbowkit/styles.css";

Expand Down Expand Up @@ -65,6 +66,11 @@ const plotlinkTheme: Theme = {
},
};

function ReferralCapture() {
useReferralCapture();
return null;
}

export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
Expand All @@ -85,6 +91,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider theme={plotlinkTheme} modalSize="compact">
<Suspense fallback={null}>
<ReferralCapture />
</Suspense>
{children}
</RainbowKitProvider>
</QueryClientProvider>
Expand Down
Loading
Loading