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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "plotlink",
"version": "1.33.0",
"version": "1.33.1",
"private": true,
"workspaces": [
"packages/*"
Expand Down
93 changes: 93 additions & 0 deletions src/app/api/airdrop/referral-code/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// @vitest-environment node
import { describe, expect, it, vi, beforeEach } from "vitest";

const mockInsert = vi.fn().mockReturnValue({ error: null });
const mockSelectCode = vi.fn();

vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({
verifySiweRequest: vi.fn(),
}));
vi.mock("../../../../../lib/rate-limit", () => ({
checkRateLimit: () => Promise.resolve(true),
getClientIp: () => "127.0.0.1",
}));
vi.mock("../../../../../lib/supabase", () => ({
createServerClient: () => ({
from: (table: string) => {
if (table === "pl_referral_codes") {
return {
select: () => ({ eq: () => ({ single: mockSelectCode }) }),
insert: mockInsert,
};
}
return {};
},
}),
}));
vi.mock("nanoid", () => ({ nanoid: () => "testcode" }));

import { GET, POST } from "./route";
import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify";
import { NextRequest } from "next/server";

function makePostReq(body: unknown) {
return new Request("http://localhost/api/airdrop/referral-code", {
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
}

beforeEach(() => {
vi.clearAllMocks();
mockInsert.mockReturnValue({ error: null });
});

describe("POST /api/airdrop/referral-code", () => {
it("returns 401 on expired SIWE signature", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "expired" });
const res = await POST(makePostReq({ message: "m", signature: "s" }));
expect(res.status).toBe(401);
expect(mockInsert).not.toHaveBeenCalled();
});

it("returns 401 on wrong chainId", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "chain_id_mismatch" });
const res = await POST(makePostReq({ message: "m", signature: "s" }));
expect(res.status).toBe(401);
expect(mockInsert).not.toHaveBeenCalled();
});

it("succeeds with valid SIWE and generates code", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" });
mockSelectCode.mockResolvedValue({ data: null });

const res = await POST(makePostReq({ message: "m", signature: "s" }));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.code).toBe("testcode");
});
});

describe("GET /api/airdrop/referral-code", () => {
it("remains unauthenticated — returns code without auth headers", async () => {
mockSelectCode.mockResolvedValue({ data: { code: "mycode", is_farcaster_username: false } });
const req = new NextRequest("http://localhost/api/airdrop/referral-code?address=0xabc");

const res = await GET(req);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.code).toBe("mycode");
expect(data).toHaveProperty("is_farcaster_username");
});

it("returns { code: null } for unknown address", async () => {
mockSelectCode.mockResolvedValue({ data: null });
const req = new NextRequest("http://localhost/api/airdrop/referral-code?address=0xnone");

const res = await GET(req);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.code).toBeNull();
});
});
11 changes: 6 additions & 5 deletions src/app/api/airdrop/referral-code/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import { NextResponse, type NextRequest } from "next/server";
import { nanoid } from "nanoid";
import { createServerClient } from "../../../../../lib/supabase";
import { verifyWalletOwnership } from "../../../../../lib/airdrop/verify-wallet";
import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify";
import { checkRateLimit, getClientIp } from "../../../../../lib/rate-limit";

export async function GET(req: NextRequest) {
Expand Down Expand Up @@ -48,7 +48,7 @@ export async function POST(req: Request) {
}

let message: string;
let signature: `0x${string}`;
let signature: string;
let useFarcasterUsername: boolean;
try {
const body = await req.json();
Expand All @@ -60,10 +60,11 @@ export async function POST(req: Request) {
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 });
const auth = await verifySiweRequest(message, signature);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: 401 });
}
const address = auth.address;

// Check for existing code (immutable once set)
const { data: existing } = await supabase
Expand Down
73 changes: 73 additions & 0 deletions src/app/api/airdrop/register-referral/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// @vitest-environment node
import { describe, expect, it, vi, beforeEach } from "vitest";

const mockInsert = vi.fn().mockReturnValue({ error: null });
const mockSelectReferred = vi.fn();
const mockSelectCode = vi.fn();

vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({
verifySiweRequest: vi.fn(),
}));
vi.mock("../../../../../lib/rate-limit", () => ({
checkRateLimit: () => Promise.resolve(true),
getClientIp: () => "127.0.0.1",
}));
vi.mock("../../../../../lib/supabase", () => ({
createServerClient: () => ({
from: (table: string) => {
if (table === "pl_referrals") {
return {
select: () => ({ eq: () => ({ single: mockSelectReferred }) }),
insert: mockInsert,
};
}
if (table === "pl_referral_codes") {
return { select: () => ({ eq: () => ({ single: mockSelectCode }) }) };
}
return {};
},
}),
}));

import { POST } from "./route";
import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify";

function makeReq(body: unknown) {
return new Request("http://localhost/api/airdrop/register-referral", {
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
}

beforeEach(() => {
vi.clearAllMocks();
mockInsert.mockReturnValue({ error: null });
});

describe("POST /api/airdrop/register-referral", () => {
it("returns 401 on expired SIWE signature", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "expired" });
const res = await POST(makeReq({ message: "m", signature: "s", referralCode: "abc" }));
expect(res.status).toBe(401);
expect(mockInsert).not.toHaveBeenCalled();
});

it("returns 401 on wrong domain", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "domain_mismatch" });
const res = await POST(makeReq({ message: "m", signature: "s", referralCode: "abc" }));
expect(res.status).toBe(401);
expect(mockInsert).not.toHaveBeenCalled();
});

it("succeeds with valid SIWE signature", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" });
mockSelectReferred.mockResolvedValue({ data: null });
mockSelectCode.mockResolvedValue({ data: { address: "0xref" } });

const res = await POST(makeReq({ message: "m", signature: "s", referralCode: "code1" }));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
});
});
11 changes: 6 additions & 5 deletions src/app/api/airdrop/register-referral/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import { NextResponse, type NextRequest } from "next/server";
import { createServerClient } from "../../../../../lib/supabase";
import { verifyWalletOwnership } from "../../../../../lib/airdrop/verify-wallet";
import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify";
import { checkRateLimit, getClientIp } from "../../../../../lib/rate-limit";

export async function GET(req: NextRequest) {
Expand Down Expand Up @@ -64,7 +64,7 @@ export async function POST(req: Request) {
}

let message: string;
let signature: `0x${string}`;
let signature: string;
let referralCode: string;
try {
const body = await req.json();
Expand All @@ -76,10 +76,11 @@ export async function POST(req: Request) {
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 });
const auth = await verifySiweRequest(message, signature);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: 401 });
}
const address = auth.address;

// Check if already referred
const { data: existing } = await supabase
Expand Down
Loading