From 5539881a9616857a0b9c063e4b4441e819f2bb0b Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 06:48:32 +0000 Subject: [PATCH 1/4] [#1245] Add /projection endpoint (canonical v5 contribution API) GET /api/airdrop/projection?address=X returns buy_volume, qualified_refs, has_fc_bonus, multiplier, weighted_spend, community_total, and projected_share at all 4 milestone tiers. Computes weighted spend using same eligibility logic as T2.4b SQL helper. Cache-Control: public, max-age=30. 6 tests cover activated wallet, no-buys zeros, non-activated 404, blacklisted 404, cache header, missing param. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 4 +- package.json | 2 +- src/app/api/airdrop/projection/route.test.ts | 138 +++++++++++++++++++ src/app/api/airdrop/projection/route.ts | 117 ++++++++++++++++ 4 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 src/app/api/airdrop/projection/route.test.ts create mode 100644 src/app/api/airdrop/projection/route.ts 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..2b1c1cf --- /dev/null +++ b/src/app/api/airdrop/projection/route.test.ts @@ -0,0 +1,138 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; + +const mockActivationSingle = vi.fn(); +const mockEligible = vi.fn(); +const mockBuys = vi.fn(); +const mockReferrals = vi.fn(); + +vi.mock("../../../../../lib/supabase", () => ({ + createServerClient: () => ({ + from: (table: string) => { + if (table === "pl_activations") { + return { + select: () => ({ + eq: () => ({ single: mockActivationSingle }), + not: () => ({ eq: mockEligible }), + }), + }; + } + if (table === "pl_points") { + return { select: () => ({ eq: () => ({ gte: () => ({ lte: mockBuys }) }) }) }; + } + if (table === "pl_referrals") { + return { select: mockReferrals }; + } + 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); +} + +function setupMocks(opts: { + activation?: unknown; + eligible?: unknown[]; + buys?: unknown[]; + referrals?: unknown[]; +}) { + mockActivationSingle.mockResolvedValue({ data: opts.activation ?? null }); + mockEligible.mockResolvedValue({ data: opts.eligible ?? [] }); + mockBuys.mockResolvedValue({ data: opts.buys ?? [] }); + mockReferrals.mockResolvedValue({ data: opts.referrals ?? [] }); +} + +describe("GET /api/airdrop/projection", () => { + it("returns projected shares for activated wallet with buys", async () => { + setupMocks({ + activation: { address: "alice", fid: 123, activated_at: "2026-07-01", is_blacklisted: false }, + eligible: [ + { address: "alice", fid: 123 }, + { address: "bob", fid: null }, + ], + buys: [ + { address: "alice", points: 100 }, + { address: "bob", points: 200 }, + ], + referrals: [], + }); + + const res = await GET(makeReq("alice")); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.buy_volume).toBe(100); + expect(data.has_fc_bonus).toBe(true); + expect(data.multiplier).toBe(1.2); + expect(data.weighted_spend).toBeCloseTo(120); + expect(data.community_total).toBeCloseTo(320); + expect(data.projected_share.diamond).toBeCloseTo(200_000 * (120 / 320)); + }); + + it("returns zeros for activated wallet with no buys", async () => { + setupMocks({ + activation: { address: "alice", fid: null, activated_at: "2026-07-01", is_blacklisted: false }, + eligible: [{ address: "alice", fid: null }], + buys: [], + referrals: [], + }); + + 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 () => { + setupMocks({ activation: null }); + const res = await GET(makeReq("0xnone")); + expect(res.status).toBe(404); + }); + + it("returns 404 for blacklisted wallet", async () => { + setupMocks({ + activation: { address: "bad", fid: null, 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 () => { + setupMocks({ + activation: { address: "alice", fid: null, activated_at: "2026-07-01", is_blacklisted: false }, + eligible: [{ address: "alice", fid: null }], + buys: [], + }); + 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); + }); +}); diff --git a/src/app/api/airdrop/projection/route.ts b/src/app/api/airdrop/projection/route.ts new file mode 100644 index 0000000..b7ddd17 --- /dev/null +++ b/src/app/api/airdrop/projection/route.ts @@ -0,0 +1,117 @@ +import { NextResponse } from "next/server"; +import { createServerClient } from "../../../../../lib/supabase"; +import { getAirdropConfig } from "../../../../../lib/airdrop/config"; + +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 campStart = config.CAMPAIGN_START.toISOString(); + const campEnd = config.CAMPAIGN_END.toISOString(); + + const { data: activation } = await supabase + .from("pl_activations") + .select("address, fid, 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" } }, + ); + } + + const [allEligible, allBuys, allReferrals] = await Promise.all([ + supabase + .from("pl_activations") + .select("address, fid") + .not("activated_at", "is", null) + .eq("is_blacklisted", false), + supabase + .from("pl_points") + .select("address, points") + .eq("action", "buy") + .gte("created_at", campStart) + .lte("created_at", campEnd), + supabase + .from("pl_referrals") + .select("referrer_address, referred_address"), + ]); + + const eligibleSet = new Set( + (allEligible.data ?? []).map((a) => a.address), + ); + const fcSet = new Set( + (allEligible.data ?? []).filter((a) => a.fid !== null).map((a) => a.address), + ); + + const buyMap = new Map(); + for (const p of allBuys.data ?? []) { + if (!eligibleSet.has(p.address)) continue; + buyMap.set(p.address, (buyMap.get(p.address) ?? 0) + p.points); + } + + const refCounts = new Map(); + for (const r of allReferrals.data ?? []) { + if (!eligibleSet.has(r.referred_address)) continue; + const refVol = buyMap.get(r.referred_address) ?? 0; + if (refVol < config.MIN_REFERRAL_THRESHOLD) continue; + refCounts.set(r.referrer_address, (refCounts.get(r.referrer_address) ?? 0) + 1); + } + + let communityTotal = 0; + const walletData = new Map(); + + for (const addr of eligibleSet) { + const bv = buyMap.get(addr) ?? 0; + if (bv <= 0) continue; + const qr = refCounts.get(addr) ?? 0; + const fc = fcSet.has(addr) ? 1 : 0; + const mult = Math.min( + 1 + (qr + fc) * config.REFERRAL_MULTIPLIER_PER_REF, + config.REFERRAL_MULTIPLIER_CAP, + ); + const ws = bv * mult; + communityTotal += ws; + walletData.set(addr, { buyVol: bv, qr, fc, mult, ws }); + } + + const me = walletData.get(address); + const buyVolume = me?.buyVol ?? 0; + const qualifiedRefs = me?.qr ?? 0; + const hasFcBonus = (me?.fc ?? 0) === 1; + const multiplier = me?.mult ?? 1; + const weightedSpend = me?.ws ?? 0; + 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" } }, + ); +} From 2b036be41eeceb901952399dc76eddb4044fdd86 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 06:51:22 +0000 Subject: [PATCH 2/4] =?UTF-8?q?[#1245]=20Use=20shared=20computeWeightedSpe?= =?UTF-8?q?nd,=20add=20=C2=A74=20worked=20example=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endpoint now consumes computeWeightedSpend from lib/airdrop/sql.ts (shared T2.4b helper) instead of inline reimplementation. Added TS computation function alongside SQL query in shared module. §4 worked example test: 100 PLOT + 2 refs + FC → multiplier 1.6, weighted 160, verifies all 4 projected shares against formula. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/airdrop/sql.ts | 61 +++++++++++ src/app/api/airdrop/projection/route.test.ts | 80 +++++++++----- src/app/api/airdrop/projection/route.ts | 109 ++++++++----------- 3 files changed, 155 insertions(+), 95 deletions(-) diff --git a/lib/airdrop/sql.ts b/lib/airdrop/sql.ts index a7eccb6..fd81ee0 100644 --- a/lib/airdrop/sql.ts +++ b/lib/airdrop/sql.ts @@ -15,6 +15,67 @@ export interface WeightedSpendRow { community_total: number; } +export interface Activation { address: string; activated_at: string | null; is_blacklisted: boolean; fid: number | null } +export interface BuyPoint { address: string; action: string; points: number; created_at: string } +export interface Referral { referrer_address: string; referred_address: string } + +export function computeWeightedSpend( + config: AirdropConfig, + activations: Activation[], + buyPoints: BuyPoint[], + referrals: Referral[], +): WeightedSpendRow[] { + const eligible = activations + .filter(a => a.activated_at !== null && !a.is_blacklisted) + .map(a => ({ address: a.address, has_fc_bonus: a.fid !== null ? 1 as const : 0 as const })); + + const eligibleSet = new Set(eligible.map(e => e.address)); + const campStart = config.CAMPAIGN_START.getTime(); + const campEnd = config.CAMPAIGN_END.getTime(); + + const buyMap = new Map(); + for (const p of buyPoints) { + if (p.action !== "buy" || !eligibleSet.has(p.address)) continue; + const t = new Date(p.created_at).getTime(); + if (t < campStart || t > campEnd) continue; + buyMap.set(p.address, (buyMap.get(p.address) ?? 0) + p.points); + } + + const eligibleBuys = eligible + .filter(e => (buyMap.get(e.address) ?? 0) > 0) + .map(e => ({ ...e, buy_volume: buyMap.get(e.address)! })); + + const ebSet = new Set(eligibleBuys.map(e => e.address)); + + const refCounts = new Map(); + for (const r of referrals) { + if (!ebSet.has(r.referred_address)) continue; + if ((buyMap.get(r.referred_address) ?? 0) < config.MIN_REFERRAL_THRESHOLD) continue; + refCounts.set(r.referrer_address, (refCounts.get(r.referrer_address) ?? 0) + 1); + } + + const rows: WeightedSpendRow[] = eligibleBuys.map(eb => { + const qr = refCounts.get(eb.address) ?? 0; + const multiplier = Math.min( + 1 + (qr + eb.has_fc_bonus) * config.REFERRAL_MULTIPLIER_PER_REF, + config.REFERRAL_MULTIPLIER_CAP, + ); + return { + address: eb.address, + buy_volume: eb.buy_volume, + qualified_refs: qr, + has_fc_bonus: eb.has_fc_bonus, + multiplier, + weighted_spend: eb.buy_volume * multiplier, + community_total: 0, + }; + }); + + const total = rows.reduce((s, r) => s + r.weighted_spend, 0); + for (const r of rows) r.community_total = total; + return rows; +} + export function weightedSpendQuery(config: AirdropConfig): WeightedSpendQuery { const params: (string | number)[] = [ config.CAMPAIGN_START.toISOString(), diff --git a/src/app/api/airdrop/projection/route.test.ts b/src/app/api/airdrop/projection/route.test.ts index 2b1c1cf..b4b48c6 100644 --- a/src/app/api/airdrop/projection/route.test.ts +++ b/src/app/api/airdrop/projection/route.test.ts @@ -1,20 +1,22 @@ // @vitest-environment node import { describe, expect, it, vi } from "vitest"; -const mockActivationSingle = vi.fn(); -const mockEligible = vi.fn(); +const mockActivations = vi.fn(); const mockBuys = vi.fn(); const mockReferrals = vi.fn(); +const mockActivationSingle = vi.fn(); vi.mock("../../../../../lib/supabase", () => ({ createServerClient: () => ({ from: (table: string) => { if (table === "pl_activations") { return { - select: () => ({ - eq: () => ({ single: mockActivationSingle }), - not: () => ({ eq: mockEligible }), - }), + select: (cols: string) => { + if (cols.includes("activated_at, is_blacklisted") && !cols.includes("fid")) { + return { eq: () => ({ single: mockActivationSingle }) }; + } + return mockActivations(); + }, }; } if (table === "pl_points") { @@ -54,49 +56,63 @@ function makeReq(address?: string) { } function setupMocks(opts: { - activation?: unknown; - eligible?: unknown[]; + activations?: unknown[]; buys?: unknown[]; referrals?: unknown[]; + activationSingle?: unknown; }) { - mockActivationSingle.mockResolvedValue({ data: opts.activation ?? null }); - mockEligible.mockResolvedValue({ data: opts.eligible ?? [] }); + mockActivations.mockReturnValue({ data: opts.activations ?? [] }); mockBuys.mockResolvedValue({ data: opts.buys ?? [] }); mockReferrals.mockResolvedValue({ data: opts.referrals ?? [] }); + mockActivationSingle.mockResolvedValue({ data: opts.activationSingle ?? null }); } describe("GET /api/airdrop/projection", () => { - it("returns projected shares for activated wallet with buys", async () => { + it("§4 worked example: 100 PLOT + 2 qualified refs + FC → multiplier 1.6, weighted 160", async () => { setupMocks({ - activation: { address: "alice", fid: 123, activated_at: "2026-07-01", is_blacklisted: false }, - eligible: [ - { address: "alice", fid: 123 }, - { address: "bob", fid: null }, + activations: [ + { address: "alice", fid: 123, activated_at: "2026-07-01", is_blacklisted: false }, + { address: "ref1", fid: null, activated_at: "2026-07-01", is_blacklisted: false }, + { address: "ref2", fid: null, activated_at: "2026-07-01", is_blacklisted: false }, ], buys: [ - { address: "alice", points: 100 }, - { address: "bob", points: 200 }, + { address: "alice", action: "buy", points: 100, created_at: "2026-08-01" }, + { address: "ref1", action: "buy", points: 60, created_at: "2026-08-01" }, + { address: "ref2", action: "buy", points: 80, created_at: "2026-08-01" }, + ], + referrals: [ + { referrer_address: "alice", referred_address: "ref1" }, + { referrer_address: "alice", referred_address: "ref2" }, ], - referrals: [], }); 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).toBe(1.2); - expect(data.weighted_spend).toBeCloseTo(120); - expect(data.community_total).toBeCloseTo(320); - expect(data.projected_share.diamond).toBeCloseTo(200_000 * (120 / 320)); + expect(data.multiplier).toBeCloseTo(1.6); + expect(data.weighted_spend).toBeCloseTo(160); + + const ref1Ws = 60 * 1; + const ref2Ws = 80 * 1; + const expectedTotal = 160 + ref1Ws + ref2Ws; + expect(data.community_total).toBeCloseTo(expectedTotal); + + const share = 160 / expectedTotal; + 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 () => { setupMocks({ - activation: { address: "alice", fid: null, activated_at: "2026-07-01", is_blacklisted: false }, - eligible: [{ address: "alice", fid: null }], + activations: [{ address: "alice", fid: null, activated_at: "2026-07-01", is_blacklisted: false }], buys: [], - referrals: [], + activationSingle: { activated_at: "2026-07-01", is_blacklisted: false }, }); const res = await GET(makeReq("alice")); @@ -108,14 +124,20 @@ describe("GET /api/airdrop/projection", () => { }); it("returns 404 for non-activated wallet", async () => { - setupMocks({ activation: null }); + setupMocks({ + activations: [], + buys: [], + activationSingle: null, + }); const res = await GET(makeReq("0xnone")); expect(res.status).toBe(404); }); it("returns 404 for blacklisted wallet", async () => { setupMocks({ - activation: { address: "bad", fid: null, activated_at: "2026-07-01", is_blacklisted: true }, + activations: [{ address: "bad", fid: null, activated_at: "2026-07-01", is_blacklisted: true }], + buys: [], + activationSingle: { activated_at: "2026-07-01", is_blacklisted: true }, }); const res = await GET(makeReq("bad")); expect(res.status).toBe(404); @@ -123,9 +145,9 @@ describe("GET /api/airdrop/projection", () => { it("includes Cache-Control: public, max-age=30", async () => { setupMocks({ - activation: { address: "alice", fid: null, activated_at: "2026-07-01", is_blacklisted: false }, - eligible: [{ address: "alice", fid: null }], + activations: [{ address: "alice", fid: null, activated_at: "2026-07-01", is_blacklisted: false }], buys: [], + activationSingle: { 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"); diff --git a/src/app/api/airdrop/projection/route.ts b/src/app/api/airdrop/projection/route.ts index b7ddd17..556da86 100644 --- a/src/app/api/airdrop/projection/route.ts +++ b/src/app/api/airdrop/projection/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { createServerClient } from "../../../../../lib/supabase"; import { getAirdropConfig } from "../../../../../lib/airdrop/config"; +import { computeWeightedSpend } from "../../../../../lib/airdrop/sql"; export async function GET(req: Request) { const { searchParams } = new URL(req.url); @@ -19,92 +20,68 @@ export async function GET(req: Request) { const campStart = config.CAMPAIGN_START.toISOString(); const campEnd = config.CAMPAIGN_END.toISOString(); - const { data: activation } = await supabase - .from("pl_activations") - .select("address, fid, 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" } }, - ); - } - - const [allEligible, allBuys, allReferrals] = await Promise.all([ - supabase - .from("pl_activations") - .select("address, fid") - .not("activated_at", "is", null) - .eq("is_blacklisted", false), + const [activationsRes, buysRes, referralsRes] = await Promise.all([ + supabase.from("pl_activations").select("address, fid, activated_at, is_blacklisted"), supabase .from("pl_points") - .select("address, points") + .select("address, action, points, created_at") .eq("action", "buy") .gte("created_at", campStart) .lte("created_at", campEnd), - supabase - .from("pl_referrals") - .select("referrer_address, referred_address"), + supabase.from("pl_referrals").select("referrer_address, referred_address"), ]); - const eligibleSet = new Set( - (allEligible.data ?? []).map((a) => a.address), - ); - const fcSet = new Set( - (allEligible.data ?? []).filter((a) => a.fid !== null).map((a) => a.address), + const rows = computeWeightedSpend( + config, + (activationsRes.data ?? []) as Parameters[1], + (buysRes.data ?? []) as Parameters[2], + (referralsRes.data ?? []) as Parameters[3], ); - const buyMap = new Map(); - for (const p of allBuys.data ?? []) { - if (!eligibleSet.has(p.address)) continue; - buyMap.set(p.address, (buyMap.get(p.address) ?? 0) + p.points); - } + const me = rows.find(r => r.address === address); - const refCounts = new Map(); - for (const r of allReferrals.data ?? []) { - if (!eligibleSet.has(r.referred_address)) continue; - const refVol = buyMap.get(r.referred_address) ?? 0; - if (refVol < config.MIN_REFERRAL_THRESHOLD) continue; - refCounts.set(r.referrer_address, (refCounts.get(r.referrer_address) ?? 0) + 1); - } + if (!me) { + const { data: activation } = await supabase + .from("pl_activations") + .select("activated_at, is_blacklisted") + .eq("address", address) + .single(); - let communityTotal = 0; - const walletData = new Map(); + 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" } }, + ); + } - for (const addr of eligibleSet) { - const bv = buyMap.get(addr) ?? 0; - if (bv <= 0) continue; - const qr = refCounts.get(addr) ?? 0; - const fc = fcSet.has(addr) ? 1 : 0; - const mult = Math.min( - 1 + (qr + fc) * config.REFERRAL_MULTIPLIER_PER_REF, - config.REFERRAL_MULTIPLIER_CAP, + const pool = config.POOL_AMOUNT; + return NextResponse.json( + { + address, + buy_volume: 0, + qualified_refs: 0, + has_fc_bonus: false, + multiplier: 1, + weighted_spend: 0, + community_total: rows[0]?.community_total ?? 0, + projected_share: { bronze: 0, silver: 0, gold: 0, diamond: 0 }, + }, + { headers: { "Cache-Control": "public, max-age=30" } }, ); - const ws = bv * mult; - communityTotal += ws; - walletData.set(addr, { buyVol: bv, qr, fc, mult, ws }); } - const me = walletData.get(address); - const buyVolume = me?.buyVol ?? 0; - const qualifiedRefs = me?.qr ?? 0; - const hasFcBonus = (me?.fc ?? 0) === 1; - const multiplier = me?.mult ?? 1; - const weightedSpend = me?.ws ?? 0; - const share = communityTotal > 0 ? weightedSpend / communityTotal : 0; + const share = me.community_total > 0 ? me.weighted_spend / me.community_total : 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, + buy_volume: me.buy_volume, + qualified_refs: me.qualified_refs, + has_fc_bonus: me.has_fc_bonus === 1, + multiplier: me.multiplier, + weighted_spend: me.weighted_spend, + community_total: me.community_total, projected_share: { bronze: pool * (config.MILESTONES.BRONZE.pct / 100) * share, silver: pool * (config.MILESTONES.SILVER.pct / 100) * share, From b093627ab91190951c532b41e757089060e0e33e Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 06:55:05 +0000 Subject: [PATCH 3/4] [#1245] Execute shared SQL via Postgres function + supabase.rpc() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove computeWeightedSpend TS duplicate. Add weighted_spend Postgres function (migration 00040) that encapsulates the T2.4b SQL query. Endpoint calls supabase.rpc('weighted_spend', params) to execute the canonical shared SQL. Add RPC type to Database interface. 7 tests including §4 worked example and RPC failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/airdrop/sql.ts | 61 ---------- lib/supabase.ts | 18 +++ src/app/api/airdrop/projection/route.test.ts | 105 ++++++------------ src/app/api/airdrop/projection/route.ts | 57 +++++----- .../00040_weighted_spend_function.sql | 75 +++++++++++++ 5 files changed, 156 insertions(+), 160 deletions(-) create mode 100644 supabase/migrations/00040_weighted_spend_function.sql diff --git a/lib/airdrop/sql.ts b/lib/airdrop/sql.ts index fd81ee0..a7eccb6 100644 --- a/lib/airdrop/sql.ts +++ b/lib/airdrop/sql.ts @@ -15,67 +15,6 @@ export interface WeightedSpendRow { community_total: number; } -export interface Activation { address: string; activated_at: string | null; is_blacklisted: boolean; fid: number | null } -export interface BuyPoint { address: string; action: string; points: number; created_at: string } -export interface Referral { referrer_address: string; referred_address: string } - -export function computeWeightedSpend( - config: AirdropConfig, - activations: Activation[], - buyPoints: BuyPoint[], - referrals: Referral[], -): WeightedSpendRow[] { - const eligible = activations - .filter(a => a.activated_at !== null && !a.is_blacklisted) - .map(a => ({ address: a.address, has_fc_bonus: a.fid !== null ? 1 as const : 0 as const })); - - const eligibleSet = new Set(eligible.map(e => e.address)); - const campStart = config.CAMPAIGN_START.getTime(); - const campEnd = config.CAMPAIGN_END.getTime(); - - const buyMap = new Map(); - for (const p of buyPoints) { - if (p.action !== "buy" || !eligibleSet.has(p.address)) continue; - const t = new Date(p.created_at).getTime(); - if (t < campStart || t > campEnd) continue; - buyMap.set(p.address, (buyMap.get(p.address) ?? 0) + p.points); - } - - const eligibleBuys = eligible - .filter(e => (buyMap.get(e.address) ?? 0) > 0) - .map(e => ({ ...e, buy_volume: buyMap.get(e.address)! })); - - const ebSet = new Set(eligibleBuys.map(e => e.address)); - - const refCounts = new Map(); - for (const r of referrals) { - if (!ebSet.has(r.referred_address)) continue; - if ((buyMap.get(r.referred_address) ?? 0) < config.MIN_REFERRAL_THRESHOLD) continue; - refCounts.set(r.referrer_address, (refCounts.get(r.referrer_address) ?? 0) + 1); - } - - const rows: WeightedSpendRow[] = eligibleBuys.map(eb => { - const qr = refCounts.get(eb.address) ?? 0; - const multiplier = Math.min( - 1 + (qr + eb.has_fc_bonus) * config.REFERRAL_MULTIPLIER_PER_REF, - config.REFERRAL_MULTIPLIER_CAP, - ); - return { - address: eb.address, - buy_volume: eb.buy_volume, - qualified_refs: qr, - has_fc_bonus: eb.has_fc_bonus, - multiplier, - weighted_spend: eb.buy_volume * multiplier, - community_total: 0, - }; - }); - - const total = rows.reduce((s, r) => s + r.weighted_spend, 0); - for (const r of rows) r.community_total = total; - return rows; -} - export function weightedSpendQuery(config: AirdropConfig): WeightedSpendQuery { const params: (string | number)[] = [ config.CAMPAIGN_START.toISOString(), 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/src/app/api/airdrop/projection/route.test.ts b/src/app/api/airdrop/projection/route.test.ts index b4b48c6..f631a92 100644 --- a/src/app/api/airdrop/projection/route.test.ts +++ b/src/app/api/airdrop/projection/route.test.ts @@ -1,29 +1,15 @@ // @vitest-environment node import { describe, expect, it, vi } from "vitest"; -const mockActivations = vi.fn(); -const mockBuys = vi.fn(); -const mockReferrals = vi.fn(); +const mockRpc = vi.fn(); const mockActivationSingle = vi.fn(); vi.mock("../../../../../lib/supabase", () => ({ createServerClient: () => ({ + rpc: mockRpc, from: (table: string) => { if (table === "pl_activations") { - return { - select: (cols: string) => { - if (cols.includes("activated_at, is_blacklisted") && !cols.includes("fid")) { - return { eq: () => ({ single: mockActivationSingle }) }; - } - return mockActivations(); - }, - }; - } - if (table === "pl_points") { - return { select: () => ({ eq: () => ({ gte: () => ({ lte: mockBuys }) }) }) }; - } - if (table === "pl_referrals") { - return { select: mockReferrals }; + return { select: () => ({ eq: () => ({ single: mockActivationSingle }) }) }; } return {}; }, @@ -55,35 +41,20 @@ function makeReq(address?: string) { return new Request(url); } -function setupMocks(opts: { - activations?: unknown[]; - buys?: unknown[]; - referrals?: unknown[]; - activationSingle?: unknown; -}) { - mockActivations.mockReturnValue({ data: opts.activations ?? [] }); - mockBuys.mockResolvedValue({ data: opts.buys ?? [] }); - mockReferrals.mockResolvedValue({ data: opts.referrals ?? [] }); - mockActivationSingle.mockResolvedValue({ data: opts.activationSingle ?? null }); -} - describe("GET /api/airdrop/projection", () => { - it("§4 worked example: 100 PLOT + 2 qualified refs + FC → multiplier 1.6, weighted 160", async () => { - setupMocks({ - activations: [ - { address: "alice", fid: 123, activated_at: "2026-07-01", is_blacklisted: false }, - { address: "ref1", fid: null, activated_at: "2026-07-01", is_blacklisted: false }, - { address: "ref2", fid: null, activated_at: "2026-07-01", is_blacklisted: false }, - ], - buys: [ - { address: "alice", action: "buy", points: 100, created_at: "2026-08-01" }, - { address: "ref1", action: "buy", points: 60, created_at: "2026-08-01" }, - { address: "ref2", action: "buy", points: 80, created_at: "2026-08-01" }, - ], - referrals: [ - { referrer_address: "alice", referred_address: "ref1" }, - { referrer_address: "alice", referred_address: "ref2" }, + 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")); @@ -95,13 +66,9 @@ describe("GET /api/airdrop/projection", () => { 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 ref1Ws = 60 * 1; - const ref2Ws = 80 * 1; - const expectedTotal = 160 + ref1Ws + ref2Ws; - expect(data.community_total).toBeCloseTo(expectedTotal); - - const share = 160 / expectedTotal; + 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); @@ -109,11 +76,8 @@ describe("GET /api/airdrop/projection", () => { }); it("returns zeros for activated wallet with no buys", async () => { - setupMocks({ - activations: [{ address: "alice", fid: null, activated_at: "2026-07-01", is_blacklisted: false }], - buys: [], - activationSingle: { activated_at: "2026-07-01", is_blacklisted: false }, - }); + 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); @@ -124,31 +88,25 @@ describe("GET /api/airdrop/projection", () => { }); it("returns 404 for non-activated wallet", async () => { - setupMocks({ - activations: [], - buys: [], - activationSingle: null, - }); + 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 () => { - setupMocks({ - activations: [{ address: "bad", fid: null, activated_at: "2026-07-01", is_blacklisted: true }], - buys: [], - activationSingle: { activated_at: "2026-07-01", is_blacklisted: true }, - }); + 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 () => { - setupMocks({ - activations: [{ address: "alice", fid: null, activated_at: "2026-07-01", is_blacklisted: false }], - buys: [], - activationSingle: { activated_at: "2026-07-01", is_blacklisted: false }, - }); + 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"); }); @@ -157,4 +115,11 @@ describe("GET /api/airdrop/projection", () => { 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 index 556da86..1ea12f0 100644 --- a/src/app/api/airdrop/projection/route.ts +++ b/src/app/api/airdrop/projection/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { createServerClient } from "../../../../../lib/supabase"; import { getAirdropConfig } from "../../../../../lib/airdrop/config"; -import { computeWeightedSpend } from "../../../../../lib/airdrop/sql"; +import type { WeightedSpendRow } from "../../../../../lib/airdrop/sql"; export async function GET(req: Request) { const { searchParams } = new URL(req.url); @@ -17,28 +17,22 @@ export async function GET(req: Request) { } const config = getAirdropConfig(); - const campStart = config.CAMPAIGN_START.toISOString(); - const campEnd = config.CAMPAIGN_END.toISOString(); - const [activationsRes, buysRes, referralsRes] = await Promise.all([ - supabase.from("pl_activations").select("address, fid, activated_at, is_blacklisted"), - supabase - .from("pl_points") - .select("address, action, points, created_at") - .eq("action", "buy") - .gte("created_at", campStart) - .lte("created_at", campEnd), - supabase.from("pl_referrals").select("referrer_address, referred_address"), - ]); + 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, + }); - const rows = computeWeightedSpend( - config, - (activationsRes.data ?? []) as Parameters[1], - (buysRes.data ?? []) as Parameters[2], - (referralsRes.data ?? []) as Parameters[3], - ); + if (error) { + console.error("[projection] weighted_spend RPC failed:", error.message); + return NextResponse.json({ error: "Failed to compute projection" }, { status: 500 }); + } - const me = rows.find(r => r.address === address); + const allRows = (rows ?? []) as WeightedSpendRow[]; + const me = allRows.find(r => r.address === address); if (!me) { const { data: activation } = await supabase @@ -54,7 +48,6 @@ export async function GET(req: Request) { ); } - const pool = config.POOL_AMOUNT; return NextResponse.json( { address, @@ -63,25 +56,31 @@ export async function GET(req: Request) { has_fc_bonus: false, multiplier: 1, weighted_spend: 0, - community_total: rows[0]?.community_total ?? 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 share = me.community_total > 0 ? me.weighted_spend / me.community_total : 0; + 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: me.buy_volume, - qualified_refs: me.qualified_refs, - has_fc_bonus: me.has_fc_bonus === 1, - multiplier: me.multiplier, - weighted_spend: me.weighted_spend, - community_total: me.community_total, + 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, 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; From d90355ca921d8212d4821bcc41d4af3f9a0dcd77 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 06:58:11 +0000 Subject: [PATCH 4/4] [#1245] Single canonical SQL: weightedSpendQuery calls DB function weightedSpendQuery now returns SELECT * FROM weighted_spend(...) calling the canonical Postgres function from migration 00040. No duplicate SQL definitions. PGlite tests load the migration file directly to create the function before executing queries. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/airdrop/sql.test.ts | 4 +++ lib/airdrop/sql.ts | 57 +---------------------------------------- 2 files changed, 5 insertions(+), 56 deletions(-) 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 }; }