diff --git a/lib/airdrop/sql.test.ts b/lib/airdrop/sql.test.ts index 4a61e5a..ff01fe5 100644 --- a/lib/airdrop/sql.test.ts +++ b/lib/airdrop/sql.test.ts @@ -58,6 +58,10 @@ beforeAll(async () => { referred_address TEXT NOT NULL ); `); + const migrationSql = await import("fs").then(fs => + fs.readFileSync(new URL("../../supabase/migrations/00040_weighted_spend_function.sql", import.meta.url), "utf-8") + ); + await db.exec(migrationSql.replace(/GRANT[^;]*;/, "")); }); afterAll(async () => { diff --git a/lib/airdrop/sql.ts b/lib/airdrop/sql.ts index a7eccb6..963e383 100644 --- a/lib/airdrop/sql.ts +++ b/lib/airdrop/sql.ts @@ -24,62 +24,7 @@ export function weightedSpendQuery(config: AirdropConfig): WeightedSpendQuery { config.REFERRAL_MULTIPLIER_CAP, ]; - const sql = ` -WITH eligible AS ( - SELECT - a.address, - CASE WHEN a.fid IS NOT NULL THEN 1 ELSE 0 END AS has_fc_bonus - FROM pl_activations a - WHERE a.activated_at IS NOT NULL - AND a.is_blacklisted = FALSE -), -buys AS ( - SELECT p.address, COALESCE(SUM(p.points), 0) AS buy_volume - FROM pl_points p - WHERE p.action = 'buy' - AND p.created_at >= $1 - AND p.created_at <= $2 - GROUP BY p.address -), -eligible_buys AS ( - SELECT e.address, e.has_fc_bonus, COALESCE(b.buy_volume, 0) AS buy_volume - FROM eligible e - JOIN buys b ON e.address = b.address -), -qualified_refs AS ( - SELECT r.referrer_address AS address, COUNT(*) AS ref_count - FROM pl_referrals r - JOIN eligible_buys eb ON r.referred_address = eb.address - WHERE eb.buy_volume >= $3 - GROUP BY r.referrer_address -), -weighted AS ( - SELECT - eb.address, - eb.buy_volume, - COALESCE(qr.ref_count, 0) AS qualified_refs, - eb.has_fc_bonus, - LEAST( - 1 + (COALESCE(qr.ref_count, 0) + eb.has_fc_bonus) * $4::NUMERIC, - $5::NUMERIC - ) AS multiplier, - eb.buy_volume * LEAST( - 1 + (COALESCE(qr.ref_count, 0) + eb.has_fc_bonus) * $4::NUMERIC, - $5::NUMERIC - ) AS weighted_spend - FROM eligible_buys eb - LEFT JOIN qualified_refs qr ON eb.address = qr.address -) -SELECT - w.address, - w.buy_volume, - w.qualified_refs, - w.has_fc_bonus, - w.multiplier, - w.weighted_spend, - SUM(w.weighted_spend) OVER () AS community_total -FROM weighted w -`.trim(); + const sql = `SELECT * FROM weighted_spend($1::TIMESTAMPTZ, $2::TIMESTAMPTZ, $3::NUMERIC, $4::NUMERIC, $5::NUMERIC)`; return { sql, params }; } diff --git a/lib/supabase.ts b/lib/supabase.ts index 066eeab..5b43ec2 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -797,6 +797,24 @@ export interface Database { Args: { p_key: string; p_max_requests: number; p_window_ms: number }; Returns: boolean; }; + weighted_spend: { + Args: { + p_campaign_start: string; + p_campaign_end: string; + p_min_referral_threshold: number; + p_multiplier_per_ref: number; + p_multiplier_cap: number; + }; + Returns: Array<{ + address: string; + buy_volume: number; + qualified_refs: number; + has_fc_bonus: number; + multiplier: number; + weighted_spend: number; + community_total: number; + }>; + }; }; Enums: { [_ in never]: never; diff --git a/package-lock.json b/package-lock.json index 97ad5f8..e8350fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.32.2", + "version": "1.33.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.32.2", + "version": "1.33.0", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index 211044a..96d16ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.32.2", + "version": "1.33.0", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/api/airdrop/projection/route.test.ts b/src/app/api/airdrop/projection/route.test.ts new file mode 100644 index 0000000..f631a92 --- /dev/null +++ b/src/app/api/airdrop/projection/route.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; + +const mockRpc = vi.fn(); +const mockActivationSingle = vi.fn(); + +vi.mock("../../../../../lib/supabase", () => ({ + createServerClient: () => ({ + rpc: mockRpc, + from: (table: string) => { + if (table === "pl_activations") { + return { select: () => ({ eq: () => ({ single: mockActivationSingle }) }) }; + } + return {}; + }, + }), +})); +vi.mock("../../../../../lib/airdrop/config", () => ({ + getAirdropConfig: () => ({ + CAMPAIGN_START: new Date("2026-07-01"), + CAMPAIGN_END: new Date("2026-10-01"), + POOL_AMOUNT: 200_000, + MILESTONES: { + BRONZE: { mcap: 100_000, pct: 10 }, + SILVER: { mcap: 1_000_000, pct: 30 }, + GOLD: { mcap: 5_000_000, pct: 50 }, + DIAMOND: { mcap: 10_000_000, pct: 100 }, + }, + MIN_REFERRAL_THRESHOLD: 50, + REFERRAL_MULTIPLIER_PER_REF: 0.2, + REFERRAL_MULTIPLIER_CAP: 3.0, + }), +})); + +import { GET } from "./route"; + +function makeReq(address?: string) { + const url = address + ? `http://localhost/api/airdrop/projection?address=${address}` + : "http://localhost/api/airdrop/projection"; + return new Request(url); +} + +describe("GET /api/airdrop/projection", () => { + it("§4 worked example: 100 PLOT + 2 refs + FC → 1.6x, weighted 160", async () => { + const ref1Ws = 60; + const ref2Ws = 80; + const aliceWs = 160; + const total = aliceWs + ref1Ws + ref2Ws; + + mockRpc.mockResolvedValue({ + data: [ + { address: "alice", buy_volume: 100, qualified_refs: 2, has_fc_bonus: 1, multiplier: 1.6, weighted_spend: 160, community_total: total }, + { address: "ref1", buy_volume: 60, qualified_refs: 0, has_fc_bonus: 0, multiplier: 1, weighted_spend: 60, community_total: total }, + { address: "ref2", buy_volume: 80, qualified_refs: 0, has_fc_bonus: 0, multiplier: 1, weighted_spend: 80, community_total: total }, + ], + error: null, + }); + + const res = await GET(makeReq("alice")); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.buy_volume).toBe(100); + expect(data.qualified_refs).toBe(2); + expect(data.has_fc_bonus).toBe(true); + expect(data.multiplier).toBeCloseTo(1.6); + expect(data.weighted_spend).toBeCloseTo(160); + expect(data.community_total).toBeCloseTo(total); + + const share = 160 / total; + expect(data.projected_share.bronze).toBeCloseTo(200_000 * 0.10 * share); + expect(data.projected_share.silver).toBeCloseTo(200_000 * 0.30 * share); + expect(data.projected_share.gold).toBeCloseTo(200_000 * 0.50 * share); + expect(data.projected_share.diamond).toBeCloseTo(200_000 * 1.00 * share); + }); + + it("returns zeros for activated wallet with no buys", async () => { + mockRpc.mockResolvedValue({ data: [], error: null }); + mockActivationSingle.mockResolvedValue({ data: { activated_at: "2026-07-01", is_blacklisted: false } }); + + const res = await GET(makeReq("alice")); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.buy_volume).toBe(0); + expect(data.weighted_spend).toBe(0); + expect(data.projected_share.diamond).toBe(0); + }); + + it("returns 404 for non-activated wallet", async () => { + mockRpc.mockResolvedValue({ data: [], error: null }); + mockActivationSingle.mockResolvedValue({ data: null }); + + const res = await GET(makeReq("0xnone")); + expect(res.status).toBe(404); + }); + + it("returns 404 for blacklisted wallet", async () => { + mockRpc.mockResolvedValue({ data: [], error: null }); + mockActivationSingle.mockResolvedValue({ data: { activated_at: "2026-07-01", is_blacklisted: true } }); + + const res = await GET(makeReq("bad")); + expect(res.status).toBe(404); + }); + + it("includes Cache-Control: public, max-age=30", async () => { + mockRpc.mockResolvedValue({ data: [], error: null }); + mockActivationSingle.mockResolvedValue({ data: { activated_at: "2026-07-01", is_blacklisted: false } }); + + const res = await GET(makeReq("alice")); + expect(res.headers.get("Cache-Control")).toBe("public, max-age=30"); + }); + + it("returns 400 when address is missing", async () => { + const res = await GET(makeReq()); + expect(res.status).toBe(400); + }); + + it("returns 500 when RPC fails", async () => { + mockRpc.mockResolvedValue({ data: null, error: { message: "function not found" } }); + + const res = await GET(makeReq("alice")); + expect(res.status).toBe(500); + }); +}); diff --git a/src/app/api/airdrop/projection/route.ts b/src/app/api/airdrop/projection/route.ts new file mode 100644 index 0000000..1ea12f0 --- /dev/null +++ b/src/app/api/airdrop/projection/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from "next/server"; +import { createServerClient } from "../../../../../lib/supabase"; +import { getAirdropConfig } from "../../../../../lib/airdrop/config"; +import type { WeightedSpendRow } from "../../../../../lib/airdrop/sql"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const address = searchParams.get("address")?.toLowerCase(); + + if (!address) { + return NextResponse.json({ error: "address parameter required" }, { status: 400 }); + } + + const supabase = createServerClient(); + if (!supabase) { + return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); + } + + const config = getAirdropConfig(); + + const { data: rows, error } = await supabase.rpc("weighted_spend", { + p_campaign_start: config.CAMPAIGN_START.toISOString(), + p_campaign_end: config.CAMPAIGN_END.toISOString(), + p_min_referral_threshold: config.MIN_REFERRAL_THRESHOLD, + p_multiplier_per_ref: config.REFERRAL_MULTIPLIER_PER_REF, + p_multiplier_cap: config.REFERRAL_MULTIPLIER_CAP, + }); + + if (error) { + console.error("[projection] weighted_spend RPC failed:", error.message); + return NextResponse.json({ error: "Failed to compute projection" }, { status: 500 }); + } + + const allRows = (rows ?? []) as WeightedSpendRow[]; + const me = allRows.find(r => r.address === address); + + if (!me) { + const { data: activation } = await supabase + .from("pl_activations") + .select("activated_at, is_blacklisted") + .eq("address", address) + .single(); + + if (!activation || !activation.activated_at || activation.is_blacklisted) { + return NextResponse.json( + { error: "Wallet not activated or not eligible" }, + { status: 404, headers: { "Cache-Control": "public, max-age=30" } }, + ); + } + + return NextResponse.json( + { + address, + buy_volume: 0, + qualified_refs: 0, + has_fc_bonus: false, + multiplier: 1, + weighted_spend: 0, + community_total: Number(allRows[0]?.community_total ?? 0), + projected_share: { bronze: 0, silver: 0, gold: 0, diamond: 0 }, + }, + { headers: { "Cache-Control": "public, max-age=30" } }, + ); + } + + const buyVolume = Number(me.buy_volume); + const qualifiedRefs = Number(me.qualified_refs); + const hasFcBonus = Number(me.has_fc_bonus) === 1; + const multiplier = Number(me.multiplier); + const weightedSpend = Number(me.weighted_spend); + const communityTotal = Number(me.community_total); + const share = communityTotal > 0 ? weightedSpend / communityTotal : 0; + const pool = config.POOL_AMOUNT; + + return NextResponse.json( + { + address, + buy_volume: buyVolume, + qualified_refs: qualifiedRefs, + has_fc_bonus: hasFcBonus, + multiplier, + weighted_spend: weightedSpend, + community_total: communityTotal, + projected_share: { + bronze: pool * (config.MILESTONES.BRONZE.pct / 100) * share, + silver: pool * (config.MILESTONES.SILVER.pct / 100) * share, + gold: pool * (config.MILESTONES.GOLD.pct / 100) * share, + diamond: pool * (config.MILESTONES.DIAMOND.pct / 100) * share, + }, + }, + { headers: { "Cache-Control": "public, max-age=30" } }, + ); +} diff --git a/supabase/migrations/00040_weighted_spend_function.sql b/supabase/migrations/00040_weighted_spend_function.sql new file mode 100644 index 0000000..e7290e3 --- /dev/null +++ b/supabase/migrations/00040_weighted_spend_function.sql @@ -0,0 +1,75 @@ +CREATE OR REPLACE FUNCTION weighted_spend( + p_campaign_start TIMESTAMPTZ, + p_campaign_end TIMESTAMPTZ, + p_min_referral_threshold NUMERIC, + p_multiplier_per_ref NUMERIC, + p_multiplier_cap NUMERIC +) +RETURNS TABLE ( + address TEXT, + buy_volume NUMERIC, + qualified_refs BIGINT, + has_fc_bonus INTEGER, + multiplier NUMERIC, + weighted_spend NUMERIC, + community_total NUMERIC +) +LANGUAGE SQL STABLE +AS $$ + WITH eligible AS ( + SELECT + a.address, + CASE WHEN a.fid IS NOT NULL THEN 1 ELSE 0 END AS has_fc_bonus + FROM pl_activations a + WHERE a.activated_at IS NOT NULL + AND a.is_blacklisted = FALSE + ), + buys AS ( + SELECT p.address, COALESCE(SUM(p.points), 0) AS buy_volume + FROM pl_points p + WHERE p.action = 'buy' + AND p.created_at >= p_campaign_start + AND p.created_at <= p_campaign_end + GROUP BY p.address + ), + eligible_buys AS ( + SELECT e.address, e.has_fc_bonus, COALESCE(b.buy_volume, 0) AS buy_volume + FROM eligible e + JOIN buys b ON e.address = b.address + ), + qualified_refs AS ( + SELECT r.referrer_address AS address, COUNT(*) AS ref_count + FROM pl_referrals r + JOIN eligible_buys eb ON r.referred_address = eb.address + WHERE eb.buy_volume >= p_min_referral_threshold + GROUP BY r.referrer_address + ), + weighted AS ( + SELECT + eb.address, + eb.buy_volume, + COALESCE(qr.ref_count, 0) AS qualified_refs, + eb.has_fc_bonus, + LEAST( + 1 + (COALESCE(qr.ref_count, 0) + eb.has_fc_bonus) * p_multiplier_per_ref, + p_multiplier_cap + ) AS multiplier, + eb.buy_volume * LEAST( + 1 + (COALESCE(qr.ref_count, 0) + eb.has_fc_bonus) * p_multiplier_per_ref, + p_multiplier_cap + ) AS weighted_spend + FROM eligible_buys eb + LEFT JOIN qualified_refs qr ON eb.address = qr.address + ) + SELECT + w.address, + w.buy_volume, + w.qualified_refs, + w.has_fc_bonus, + w.multiplier, + w.weighted_spend, + SUM(w.weighted_spend) OVER () AS community_total + FROM weighted w; +$$; + +GRANT EXECUTE ON FUNCTION weighted_spend TO service_role;