diff --git a/lib/airdrop/config.test.ts b/lib/airdrop/config.test.ts new file mode 100644 index 00000000..2c7fc9fa --- /dev/null +++ b/lib/airdrop/config.test.ts @@ -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[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[0]; + expect(getClaimWindowSeconds(config)).toBe(120); + }); +}); diff --git a/lib/airdrop/config.ts b/lib/airdrop/config.ts index 8d72b866..f8d82e54 100644 --- a/lib/airdrop/config.ts +++ b/lib/airdrop/config.ts @@ -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; @@ -30,10 +23,24 @@ export interface AirdropConfig { }; readonly STREAK_BOOSTS: Record; 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, @@ -42,51 +49,109 @@ const POINTS = { RATE_DAILY_CAP: 10, } as const; -const STREAK_BOOSTS: Record = { - 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()); diff --git a/lib/supabase.ts b/lib/supabase.ts index 8bb476f5..066eeab7 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -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; @@ -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"]; diff --git a/package.json b/package.json index 2f49e88d..0173728e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.29.4", + "version": "1.30.0", "private": true, "workspaces": [ "packages/*"