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
107 changes: 107 additions & 0 deletions lib/airdrop/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

beforeEach(() => {
vi.unstubAllEnvs();
});

describe("getAirdropMode", () => {
it("returns 'prod' when env is unset", async () => {
vi.stubEnv("NEXT_PUBLIC_AIRDROP_MODE", "");
const { getAirdropMode } = await import("./config");
expect(getAirdropMode()).toBe("prod");
});

it("returns 'prod' for unknown values", async () => {
vi.stubEnv("NEXT_PUBLIC_AIRDROP_MODE", "staging");
const { getAirdropMode } = await import("./config");
expect(getAirdropMode()).toBe("prod");
});

it("returns 'test-fast'", async () => {
vi.stubEnv("NEXT_PUBLIC_AIRDROP_MODE", "test-fast");
const { getAirdropMode } = await import("./config");
expect(getAirdropMode()).toBe("test-fast");
});

it("returns 'test-full'", async () => {
vi.stubEnv("NEXT_PUBLIC_AIRDROP_MODE", "test-full");
const { getAirdropMode } = await import("./config");
expect(getAirdropMode()).toBe("test-full");
});
});

describe("getAirdropConfig", () => {
it("returns PROD config with static dates for prod mode", async () => {
vi.stubEnv("NEXT_PUBLIC_AIRDROP_MODE", "");
const { getAirdropConfig } = await import("./config");
const config = getAirdropConfig();
expect(config.POOL_AMOUNT).toBe(200_000);
expect(config.CLAIM_WINDOW_DAYS).toBe(30);
expect(config.CLAIM_WINDOW_SECONDS).toBeUndefined();
});

it("returns test-fast config with 5-min window", async () => {
vi.stubEnv("NEXT_PUBLIC_AIRDROP_MODE", "test-fast");
const { getAirdropConfig } = await import("./config");
const now = new Date("2026-06-01T12:00:00Z");
const config = getAirdropConfig(now);
expect(config.POOL_AMOUNT).toBe(10);
expect(config.CLAIM_WINDOW_SECONDS).toBe(60);
expect(config.CLAIM_WINDOW_DAYS).toBeUndefined();
expect(config.CAMPAIGN_START.getTime()).toBe(now.getTime() - 60_000);
expect(config.CAMPAIGN_END.getTime()).toBe(now.getTime() + 4 * 60_000);
});

it("returns test-full config with 30-min window", async () => {
vi.stubEnv("NEXT_PUBLIC_AIRDROP_MODE", "test-full");
const { getAirdropConfig } = await import("./config");
const now = new Date("2026-06-01T12:00:00Z");
const config = getAirdropConfig(now);
expect(config.POOL_AMOUNT).toBe(100);
expect(config.CLAIM_WINDOW_SECONDS).toBe(180);
expect(config.MIN_REFERRAL_THRESHOLD).toBe(5);
expect(config.CAMPAIGN_END.getTime()).toBe(now.getTime() + 29 * 60_000);
});

it("builds fresh test configs per call — no frozen-at-import bug", async () => {
vi.stubEnv("NEXT_PUBLIC_AIRDROP_MODE", "test-full");
const { getAirdropConfig } = await import("./config");
const t1 = new Date("2026-06-01T12:00:00Z");
const t2 = new Date("2026-06-01T12:05:00Z");
const config1 = getAirdropConfig(t1);
const config2 = getAirdropConfig(t2);
expect(config2.CAMPAIGN_END.getTime()).toBe(t2.getTime() + 29 * 60_000);
expect(config2.CAMPAIGN_END.getTime()).not.toBe(config1.CAMPAIGN_END.getTime());
expect(config2.CAMPAIGN_END.getTime() - config1.CAMPAIGN_END.getTime()).toBe(5 * 60_000);
});
});

describe("getClaimWindowSeconds", () => {
it("returns CLAIM_WINDOW_SECONDS when set", async () => {
vi.stubEnv("NEXT_PUBLIC_AIRDROP_MODE", "test-fast");
const { getAirdropConfig, getClaimWindowSeconds } = await import("./config");
const config = getAirdropConfig(new Date());
expect(getClaimWindowSeconds(config)).toBe(60);
});

it("converts CLAIM_WINDOW_DAYS to seconds for PROD", async () => {
vi.stubEnv("NEXT_PUBLIC_AIRDROP_MODE", "");
const { getAirdropConfig, getClaimWindowSeconds } = await import("./config");
const config = getAirdropConfig();
expect(getClaimWindowSeconds(config)).toBe(30 * 86400);
});

it("throws when neither field is set", async () => {
const { getClaimWindowSeconds } = await import("./config");
const bad = { POOL_AMOUNT: 0 } as Parameters<typeof getClaimWindowSeconds>[0];
expect(() => getClaimWindowSeconds(bad)).toThrow(
"AirdropConfig must specify CLAIM_WINDOW_DAYS or CLAIM_WINDOW_SECONDS",
);
});

it("prefers CLAIM_WINDOW_SECONDS over CLAIM_WINDOW_DAYS when both set", async () => {
const { getClaimWindowSeconds } = await import("./config");
const config = { CLAIM_WINDOW_SECONDS: 120, CLAIM_WINDOW_DAYS: 7 } as Parameters<typeof getClaimWindowSeconds>[0];
expect(getClaimWindowSeconds(config)).toBe(120);
});
});
145 changes: 105 additions & 40 deletions lib/airdrop/config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
/**
* Airdrop campaign configuration (#879)
*
* Switch between test/prod via NEXT_PUBLIC_AIRDROP_MODE env var.
* Test mode uses small pool + low milestones for 3-day test runs.
*/

export interface Milestone {
readonly mcap: number;
readonly pct: number;
Expand All @@ -30,10 +23,24 @@ export interface AirdropConfig {
};
readonly STREAK_BOOSTS: Record<number, number>;
readonly STREAK_MIN_GAP_MINUTES: number;
readonly MIN_REFERRAL_THRESHOLD: number;
readonly REFERRAL_MULTIPLIER_PER_REF: number;
readonly REFERRAL_MULTIPLIER_CAP: number;
readonly SIGNATURE_FRESHNESS_MIN: number;
readonly SIWE_DOMAIN: string;
readonly SIWE_URI: string;
readonly SIWE_STATEMENT: string;
readonly SIWE_CHAIN_ID: number;
readonly PLOTLINK_X_HANDLE: string;
readonly PLOTLINK_FC_FID: number;
readonly CLAIM_WINDOW_DAYS?: number;
readonly CLAIM_WINDOW_SECONDS?: number;
}

export type MilestoneTier = keyof AirdropConfig["MILESTONES"];

export type AirdropMode = "test-fast" | "test-full" | "prod";

const POINTS = {
BUY_PER_PLOT: 1,
REFERRAL_PCT: 20,
Expand All @@ -42,51 +49,109 @@ const POINTS = {
RATE_DAILY_CAP: 10,
} as const;

const STREAK_BOOSTS: Record<number, number> = {
7: 0.05,
14: 0.10,
30: 0.20,
50: 0.30,
100: 0.50,
};

const STREAK_MIN_GAP_MINUTES = 30;

const TEST_CONFIG: AirdropConfig = {
CAMPAIGN_START: new Date("2026-05-04"),
CAMPAIGN_END: new Date("2026-05-11"), // start + 7 days
POOL_AMOUNT: 10, // 10 PLOT
MILESTONES: {
BRONZE: { mcap: 1_000_000, pct: 10 },
SILVER: { mcap: 10_000_000, pct: 30 },
GOLD: { mcap: 50_000_000, pct: 50 },
DIAMOND: { mcap: 100_000_000, pct: 100 },
},
LOCKER_TX: "0xb4549d21a3a026d215f38d9bf50779fe337254944ae746d008b3b13cd684adce",
POINTS,
STREAK_BOOSTS,
STREAK_MIN_GAP_MINUTES,
};
function getSiweCommon() {
return {
SIWE_DOMAIN: "plotlink.xyz" as const,
SIWE_URI: "https://plotlink.xyz/airdrop" as const,
SIWE_STATEMENT: "PlotLink Buy-Back Sprint activation" as const,
SIWE_CHAIN_ID: 8453 as const,
PLOTLINK_X_HANDLE: "plotlinkxyz" as const,
PLOTLINK_FC_FID: Number(process.env.NEXT_PUBLIC_PLOTLINK_FC_FID) || 0,
};
}

const PROD_CONFIG: AirdropConfig = {
CAMPAIGN_START: new Date("2026-07-01"),
CAMPAIGN_END: new Date("2027-01-01"), // start + 6 months
POOL_AMOUNT: 50_000,
CAMPAIGN_END: new Date("2026-10-01"),
POOL_AMOUNT: 200_000,
MILESTONES: {
BRONZE: { mcap: 1_000_000, pct: 10 },
SILVER: { mcap: 10_000_000, pct: 30 },
GOLD: { mcap: 50_000_000, pct: 50 },
DIAMOND: { mcap: 100_000_000, pct: 100 },
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 },
},
LOCKER_TX: null,
POINTS,
STREAK_BOOSTS,
STREAK_BOOSTS: {},
STREAK_MIN_GAP_MINUTES,
MIN_REFERRAL_THRESHOLD: 50,
REFERRAL_MULTIPLIER_PER_REF: 0.2,
REFERRAL_MULTIPLIER_CAP: 3.0,
SIGNATURE_FRESHNESS_MIN: 10,
CLAIM_WINDOW_DAYS: 30,
...getSiweCommon(),
};

const mode = process.env.NEXT_PUBLIC_AIRDROP_MODE ?? "prod";
function buildTestFastConfig(now: Date): AirdropConfig {
return {
CAMPAIGN_START: new Date(now.getTime() - 60_000),
CAMPAIGN_END: new Date(now.getTime() + 4 * 60_000),
POOL_AMOUNT: 10,
MILESTONES: {
BRONZE: { mcap: 1, pct: 10 },
SILVER: { mcap: 10, pct: 30 },
GOLD: { mcap: 100, pct: 50 },
DIAMOND: { mcap: 1000, pct: 100 },
},
LOCKER_TX: null,
POINTS,
STREAK_BOOSTS: {},
STREAK_MIN_GAP_MINUTES,
MIN_REFERRAL_THRESHOLD: 1,
REFERRAL_MULTIPLIER_PER_REF: 0.2,
REFERRAL_MULTIPLIER_CAP: 3.0,
SIGNATURE_FRESHNESS_MIN: 10,
CLAIM_WINDOW_SECONDS: 60,
...getSiweCommon(),
};
}

function buildTestFullConfig(now: Date): AirdropConfig {
return {
CAMPAIGN_START: new Date(now.getTime() - 60_000),
CAMPAIGN_END: new Date(now.getTime() + 29 * 60_000),
POOL_AMOUNT: 100,
MILESTONES: {
BRONZE: { mcap: 1, pct: 10 },
SILVER: { mcap: 10, pct: 30 },
GOLD: { mcap: 100, pct: 50 },
DIAMOND: { mcap: 1000, pct: 100 },
},
LOCKER_TX: null,
POINTS,
STREAK_BOOSTS: {},
STREAK_MIN_GAP_MINUTES,
MIN_REFERRAL_THRESHOLD: 5,
REFERRAL_MULTIPLIER_PER_REF: 0.2,
REFERRAL_MULTIPLIER_CAP: 3.0,
SIGNATURE_FRESHNESS_MIN: 10,
CLAIM_WINDOW_SECONDS: 180,
...getSiweCommon(),
};
}

export const AIRDROP_TEST_MODE = mode === "test";
export function getAirdropMode(): AirdropMode {
const raw = process.env.NEXT_PUBLIC_AIRDROP_MODE;
if (raw === "test-fast") return "test-fast";
if (raw === "test-full") return "test-full";
return "prod";
}

export function getAirdropConfig(now: Date = new Date()): AirdropConfig {
switch (getAirdropMode()) {
case "test-fast": return buildTestFastConfig(now);
case "test-full": return buildTestFullConfig(now);
default: return PROD_CONFIG;
}
}

export function getClaimWindowSeconds(config: AirdropConfig): number {
if (config.CLAIM_WINDOW_SECONDS !== undefined) return config.CLAIM_WINDOW_SECONDS;
if (config.CLAIM_WINDOW_DAYS !== undefined) return config.CLAIM_WINDOW_DAYS * 86400;
throw new Error("AirdropConfig must specify CLAIM_WINDOW_DAYS or CLAIM_WINDOW_SECONDS");
}

export const AIRDROP_CONFIG: AirdropConfig =
AIRDROP_TEST_MODE ? TEST_CONFIG : PROD_CONFIG;
getAirdropMode() === "prod" ? PROD_CONFIG : getAirdropConfig(new Date());
43 changes: 43 additions & 0 deletions lib/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,48 @@ export interface Database {
};
Relationships: [];
};
pl_activations: {
Row: {
address: string;
x_handle: string | null;
x_user_id: string | null;
x_handle_confirmed_at: string | null;
x_follow_at: string | null;
fid: number | null;
fc_handle: string | null;
fc_verified_at: string | null;
activated_at: string | null;
is_blacklisted: boolean;
created_at: string;
};
Insert: {
address: string;
x_handle?: string | null;
x_user_id?: string | null;
x_handle_confirmed_at?: string | null;
x_follow_at?: string | null;
fid?: number | null;
fc_handle?: string | null;
fc_verified_at?: string | null;
activated_at?: string | null;
is_blacklisted?: boolean;
created_at?: string;
};
Update: {
address?: string;
x_handle?: string | null;
x_user_id?: string | null;
x_handle_confirmed_at?: string | null;
x_follow_at?: string | null;
fid?: number | null;
fc_handle?: string | null;
fc_verified_at?: string | null;
activated_at?: string | null;
is_blacklisted?: boolean;
created_at?: string;
};
Relationships: [];
};
rate_limits: {
Row: {
id: number;
Expand Down Expand Up @@ -780,3 +822,4 @@ export type PlStreak = Database["public"]["Tables"]["pl_streaks"]["Row"];
export type PlDailyPrice = Database["public"]["Tables"]["pl_daily_prices"]["Row"];
export type PlWeeklySnapshot = Database["public"]["Tables"]["pl_weekly_snapshots"]["Row"];
export type PlAirdropProof = Database["public"]["Tables"]["pl_airdrop_proofs"]["Row"];
export type PlActivation = Database["public"]["Tables"]["pl_activations"]["Row"];
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.29.4",
"version": "1.30.0",
"private": true,
"workspaces": [
"packages/*"
Expand Down
Loading