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: 4 additions & 0 deletions lib/airdrop/sql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
57 changes: 1 addition & 56 deletions lib/airdrop/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
18 changes: 18 additions & 0 deletions lib/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
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.32.2",
"version": "1.33.0",
"private": true,
"workspaces": [
"packages/*"
Expand Down
125 changes: 125 additions & 0 deletions src/app/api/airdrop/projection/route.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
93 changes: 93 additions & 0 deletions src/app/api/airdrop/projection/route.ts
Original file line number Diff line number Diff line change
@@ -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" } },
);
}
Loading
Loading