diff --git a/.gitignore b/.gitignore index 43220b73..92d4a66f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ # internal docs (operational queues, proposals, roadmaps — never commit) docs/ -!docs/airdrop-contracts.md # archive (data exports, scored CSVs — never commit) archive/ @@ -59,12 +58,6 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -# Foundry -contracts/out/ -contracts/cache/ -contracts/lib/ -broadcast/ - # claude code .claude/worktrees/ AGENTS.md diff --git a/contracts/script/DeployMerkleClaim.s.sol b/contracts/script/DeployMerkleClaim.s.sol deleted file mode 100644 index b97825bd..00000000 --- a/contracts/script/DeployMerkleClaim.s.sol +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import "forge-std/Script.sol"; -import "../src/MerkleClaim.sol"; - -contract DeployMerkleClaim is Script { - function run() external { - address plotToken = vm.envAddress("PLOT_TOKEN_ADDRESS"); - bytes32 merkleRoot = vm.envBytes32("MERKLE_ROOT"); - uint256 claimDeadline = vm.envUint("CLAIM_DEADLINE"); - - vm.startBroadcast(); - new MerkleClaim(plotToken, merkleRoot, claimDeadline); - vm.stopBroadcast(); - } -} diff --git a/contracts/setup.sh b/contracts/setup.sh deleted file mode 100755 index 2a132972..00000000 --- a/contracts/setup.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -set -e -cd "$(dirname "$0")/.." -forge install OpenZeppelin/openzeppelin-contracts@v5.0.0 --no-git -forge install foundry-rs/forge-std --no-git -forge build -echo "✓ Foundry contracts ready" diff --git a/contracts/src/MerkleClaim.sol b/contracts/src/MerkleClaim.sol deleted file mode 100644 index 5ad436ef..00000000 --- a/contracts/src/MerkleClaim.sol +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -/// @title MerkleClaim -/// @notice Merkle-tree based airdrop claim contract for PLOT tokens with -/// owner-gated sweep after a claim deadline. -contract MerkleClaim { - IERC20 public immutable PLOT; - bytes32 public immutable merkleRoot; - address public immutable owner; - uint256 public immutable claimDeadline; - - mapping(address => bool) public claimed; - - event Claimed(address indexed account, uint256 amount); - event Swept(address indexed to, uint256 amount); - - modifier onlyOwner() { - require(msg.sender == owner, "Not owner"); - _; - } - - constructor(address _plot, bytes32 _merkleRoot, uint256 _claimDeadline) { - PLOT = IERC20(_plot); - merkleRoot = _merkleRoot; - owner = msg.sender; - claimDeadline = _claimDeadline; - } - - function claim(uint256 amount, bytes32[] calldata proof) external { - require(block.timestamp <= claimDeadline, "Claim window closed"); - require(!claimed[msg.sender], "Already claimed"); - - bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, amount)))); - require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof"); - - claimed[msg.sender] = true; - require(PLOT.transfer(msg.sender, amount), "Transfer failed"); - - emit Claimed(msg.sender, amount); - } - - function sweepUnclaimed(address to) external onlyOwner { - require(block.timestamp > claimDeadline, "Claim window still open"); - uint256 remaining = PLOT.balanceOf(address(this)); - require(PLOT.transfer(to, remaining), "Sweep failed"); - emit Swept(to, remaining); - } -} diff --git a/contracts/test/MerkleClaim.t.sol b/contracts/test/MerkleClaim.t.sol deleted file mode 100644 index a229fde2..00000000 --- a/contracts/test/MerkleClaim.t.sol +++ /dev/null @@ -1,118 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import "forge-std/Test.sol"; -import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import "../src/MerkleClaim.sol"; - -contract MockPLOT is ERC20 { - constructor() ERC20("PLOT", "PLOT") { - _mint(msg.sender, 1_000_000 ether); - } -} - -contract MerkleClaimTest is Test { - MerkleClaim public mc; - MockPLOT public plot; - - address owner = address(this); - address alice = address(0xA11CE); - address bob = address(0xB0B); - address outsider = address(0xBAD); - - uint256 aliceAmount = 100 ether; - uint256 bobAmount = 200 ether; - - bytes32 aliceLeaf; - bytes32 bobLeaf; - bytes32 root; - bytes32[] aliceProof; - bytes32[] bobProof; - - uint256 deadline; - - function setUp() public { - plot = new MockPLOT(); - deadline = block.timestamp + 30 days; - - aliceLeaf = keccak256(bytes.concat(keccak256(abi.encode(alice, aliceAmount)))); - bobLeaf = keccak256(bytes.concat(keccak256(abi.encode(bob, bobAmount)))); - - if (aliceLeaf <= bobLeaf) { - root = keccak256(abi.encodePacked(aliceLeaf, bobLeaf)); - } else { - root = keccak256(abi.encodePacked(bobLeaf, aliceLeaf)); - } - - aliceProof = new bytes32[](1); - aliceProof[0] = bobLeaf; - bobProof = new bytes32[](1); - bobProof[0] = aliceLeaf; - - mc = new MerkleClaim(address(plot), root, deadline); - plot.transfer(address(mc), 1000 ether); - } - - function test_claim_BeforeDeadline_Succeeds() public { - vm.prank(alice); - mc.claim(aliceAmount, aliceProof); - - assertTrue(mc.claimed(alice)); - assertEq(plot.balanceOf(alice), aliceAmount); - } - - function test_claim_AfterDeadline_Reverts() public { - vm.warp(deadline + 1); - vm.prank(alice); - vm.expectRevert("Claim window closed"); - mc.claim(aliceAmount, aliceProof); - } - - function test_claim_InvalidProof_Reverts() public { - bytes32[] memory badProof = new bytes32[](1); - badProof[0] = bytes32(uint256(0xdead)); - - vm.prank(alice); - vm.expectRevert("Invalid proof"); - mc.claim(aliceAmount, badProof); - } - - function test_claim_DoubleClaim_Reverts() public { - vm.prank(alice); - mc.claim(aliceAmount, aliceProof); - - vm.prank(alice); - vm.expectRevert("Already claimed"); - mc.claim(aliceAmount, aliceProof); - } - - function test_sweep_BeforeDeadline_Reverts() public { - vm.expectRevert("Claim window still open"); - mc.sweepUnclaimed(owner); - } - - function test_sweep_AfterDeadline_BySomeone_Reverts() public { - vm.warp(deadline + 1); - vm.prank(outsider); - vm.expectRevert("Not owner"); - mc.sweepUnclaimed(outsider); - } - - function test_sweep_AfterDeadline_ByOwner_Succeeds() public { - vm.warp(deadline + 1); - uint256 balance = plot.balanceOf(address(mc)); - mc.sweepUnclaimed(owner); - - assertEq(plot.balanceOf(owner), 1_000_000 ether - 1000 ether + balance); - assertEq(plot.balanceOf(address(mc)), 0); - } - - function test_sweep_DoubleSweep_TransfersZero() public { - vm.warp(deadline + 1); - mc.sweepUnclaimed(owner); - - uint256 balBefore = plot.balanceOf(owner); - mc.sweepUnclaimed(owner); - assertEq(plot.balanceOf(owner), balBefore); - } -} diff --git a/docs/airdrop-contracts.md b/docs/airdrop-contracts.md deleted file mode 100644 index 1ad0b419..00000000 --- a/docs/airdrop-contracts.md +++ /dev/null @@ -1,67 +0,0 @@ -# MerkleClaim Contract - -## Setup (fresh clone) - -```bash -# Install Foundry (if not already installed) -curl -L https://foundry.paradigm.xyz | bash -foundryup - -# Install contract dependencies -bash contracts/setup.sh -``` - -## Test - -```bash -forge test -vv --match-path "contracts/test/*" -``` - -## Gas snapshot - -```bash -forge snapshot --match-path "contracts/test/*" -``` - -Baseline (v2 with deadline): claim path 83,648 gas. v1 (no deadline) baseline ~82,000. Delta ~+2%, well within the +5% acceptance threshold. - -## Deploy - -Set env vars (never commit real keys): - -```bash -export BASE_RPC_URL=https://mainnet.base.org -export DEPLOYER_PRIVATE_KEY= -export PLOT_TOKEN_ADDRESS= -export MERKLE_ROOT= -export CLAIM_DEADLINE= -``` - -```bash -forge script contracts/script/DeployMerkleClaim.s.sol \ - --rpc-url $BASE_RPC_URL \ - --broadcast \ - --private-key $DEPLOYER_PRIVATE_KEY -``` - -## Weighted Spend SQL Helper (T2.4b hybrid pattern) - -The weighted spend computation uses a hybrid of T0.1's option A (TS config) and option B (DB function): - -- **`lib/airdrop/sql.ts`** — TS wrapper `weightedSpendQuery(config)` returns `{sql, params}`. Config values (campaign dates, thresholds, multiplier params) flow from `getAirdropConfig()` at call time, supporting test-fast/test-full/prod modes. -- **`supabase/migrations/00041_weighted_spend_function.sql`** — Postgres function `weighted_spend(...)` is the single canonical SQL definition. All consumers (finalize script, /projection, /leaderboard) call this function. -- **`lib/airdrop/sql.ts:weightedSpendQuery()`** returns `SELECT * FROM weighted_spend($1, $2, $3, $4, $5)` — the TS wrapper delegates to the DB function. - -This avoids both option A's risk (SQL string drift across consumers) and option B's downside (DB-resident config table). - -## Settlement (finalize) - -```bash -npx tsx scripts/airdrop-finalize.ts [--dry-run] -``` - -Emergency override for partial TWAP data (<5 daily price samples): - -```bash -AIRDROP_FINALIZE_ALLOW_PARTIAL_TWAP=1 npx tsx scripts/airdrop-finalize.ts -``` diff --git a/foundry.toml b/foundry.toml deleted file mode 100644 index 7aebcc33..00000000 --- a/foundry.toml +++ /dev/null @@ -1,8 +0,0 @@ -[profile.default] -src = "contracts/src" -test = "contracts/test" -script = "contracts/script" -out = "contracts/out" -cache_path = "contracts/cache" -libs = ["contracts/lib"] -solc = "0.8.20" diff --git a/lib/airdrop/activation-helpers.test.ts b/lib/airdrop/activation-helpers.test.ts deleted file mode 100644 index 2ade7b59..00000000 --- a/lib/airdrop/activation-helpers.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { handleInboundReferral, REFERRAL_STORAGE_KEY } from "./activation-helpers"; - -beforeEach(() => { localStorage.clear(); }); - -function mockFetch(status: number) { - return vi.fn(async () => ({ - ok: status >= 200 && status < 300, - status, - json: () => Promise.resolve({}), - })) as unknown as typeof fetch; -} - -function throwingFetch() { - return vi.fn(async () => { throw new Error("network error"); }) as unknown as typeof fetch; -} - -describe("handleInboundReferral", () => { - it("does nothing when no ref in localStorage", async () => { - const fn = mockFetch(200); - await handleInboundReferral("msg", "sig", fn); - expect(fn).not.toHaveBeenCalled(); - }); - - it("POSTs to register-referral with SIWE auth", async () => { - localStorage.setItem(REFERRAL_STORAGE_KEY, "TESTCODE"); - const fn = mockFetch(200); - await handleInboundReferral("mock-msg", "mock-sig", fn); - expect(fn).toHaveBeenCalledWith( - "/api/airdrop/register-referral", - expect.objectContaining({ - method: "POST", - body: expect.stringContaining("mock-msg"), - }), - ); - const body = JSON.parse((fn as ReturnType).mock.calls[0][1].body); - expect(body.message).toBe("mock-msg"); - expect(body.signature).toBe("mock-sig"); - expect(body.referralCode).toBe("TESTCODE"); - }); - - it("clears localStorage on 200 success", async () => { - localStorage.setItem(REFERRAL_STORAGE_KEY, "REF1"); - await handleInboundReferral("m", "s", mockFetch(200)); - expect(localStorage.getItem(REFERRAL_STORAGE_KEY)).toBeNull(); - }); - - it("clears localStorage on 400 (self-referral)", async () => { - localStorage.setItem(REFERRAL_STORAGE_KEY, "REF2"); - await handleInboundReferral("m", "s", mockFetch(400)); - expect(localStorage.getItem(REFERRAL_STORAGE_KEY)).toBeNull(); - }); - - it("clears localStorage on 404 (code not found)", async () => { - localStorage.setItem(REFERRAL_STORAGE_KEY, "REF3"); - await handleInboundReferral("m", "s", mockFetch(404)); - expect(localStorage.getItem(REFERRAL_STORAGE_KEY)).toBeNull(); - }); - - it("clears localStorage on 409 (already referred)", async () => { - localStorage.setItem(REFERRAL_STORAGE_KEY, "REF4"); - await handleInboundReferral("m", "s", mockFetch(409)); - expect(localStorage.getItem(REFERRAL_STORAGE_KEY)).toBeNull(); - }); - - it("retains localStorage on 401 (transient auth error)", async () => { - localStorage.setItem(REFERRAL_STORAGE_KEY, "REF5"); - await handleInboundReferral("m", "s", mockFetch(401)); - expect(localStorage.getItem(REFERRAL_STORAGE_KEY)).toBe("REF5"); - }); - - it("retains localStorage on 500 (server error)", async () => { - localStorage.setItem(REFERRAL_STORAGE_KEY, "REF6"); - await handleInboundReferral("m", "s", mockFetch(500)); - expect(localStorage.getItem(REFERRAL_STORAGE_KEY)).toBe("REF6"); - }); - - it("retains localStorage on network failure", async () => { - localStorage.setItem(REFERRAL_STORAGE_KEY, "REF7"); - await handleInboundReferral("m", "s", throwingFetch()); - expect(localStorage.getItem(REFERRAL_STORAGE_KEY)).toBe("REF7"); - }); -}); diff --git a/lib/airdrop/activation-helpers.ts b/lib/airdrop/activation-helpers.ts deleted file mode 100644 index 43f1016c..00000000 --- a/lib/airdrop/activation-helpers.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { REFERRAL_STORAGE_KEY } from "../../src/hooks/useReferralCapture"; -export { REFERRAL_STORAGE_KEY }; - -export async function handleInboundReferral( - message: string, - signature: string, - fetchFn: typeof fetch = fetch, -): Promise { - const refCode = typeof localStorage !== "undefined" - ? localStorage.getItem(REFERRAL_STORAGE_KEY) - : null; - if (!refCode) return; - - try { - const res = await fetchFn("/api/airdrop/register-referral", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message, signature, referralCode: refCode }), - }); - if (res.ok || res.status === 400 || res.status === 404 || res.status === 409) { - localStorage.removeItem(REFERRAL_STORAGE_KEY); - } - } catch { - // transient error — keep localStorage for retry - } -} diff --git a/lib/airdrop/activation-verify.test.ts b/lib/airdrop/activation-verify.test.ts deleted file mode 100644 index c454b9b2..00000000 --- a/lib/airdrop/activation-verify.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; - -beforeEach(() => { - vi.stubEnv("NEYNAR_API_KEY", "test-neynar-key"); - vi.stubGlobal("fetch", vi.fn()); -}); - -afterEach(() => { - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); -}); - -function mockFetch(status: number, body?: unknown) { - return vi.mocked(fetch).mockResolvedValueOnce({ - ok: status >= 200 && status < 300, - status, - statusText: "OK", - json: () => Promise.resolve(body), - } as Response); -} - -const PLOTLINK_FID = 12345; - -describe("verifyFc", () => { - it("returns ok with fid when user exists and follows plotlink", async () => { - const { verifyFc } = await import("./activation-verify"); - - mockFetch(200, { user: { fid: 999 } }); - mockFetch(200, { - users: [{ fid: PLOTLINK_FID, viewer_context: { following: true } }], - }); - - const result = await verifyFc("testuser", PLOTLINK_FID); - expect(result).toEqual({ ok: true, fid: 999 }); - }); - - it("returns user_not_found when username returns 404", async () => { - const { verifyFc } = await import("./activation-verify"); - mockFetch(404); - - const result = await verifyFc("nonexistent", PLOTLINK_FID); - expect(result).toEqual({ ok: false, error: "user_not_found" }); - }); - - it("returns user_not_found when username returns 400", async () => { - const { verifyFc } = await import("./activation-verify"); - mockFetch(400); - - const result = await verifyFc("bad!", PLOTLINK_FID); - expect(result).toEqual({ ok: false, error: "user_not_found" }); - }); - - it("returns user_not_found when response has no fid", async () => { - const { verifyFc } = await import("./activation-verify"); - mockFetch(200, { user: {} }); - - const result = await verifyFc("nofid", PLOTLINK_FID); - expect(result).toEqual({ ok: false, error: "user_not_found" }); - }); - - it("returns not_following when user exists but doesn't follow", async () => { - const { verifyFc } = await import("./activation-verify"); - - mockFetch(200, { user: { fid: 999 } }); - mockFetch(200, { - users: [{ fid: PLOTLINK_FID, viewer_context: { following: false } }], - }); - - const result = await verifyFc("testuser", PLOTLINK_FID); - expect(result).toEqual({ ok: false, error: "not_following" }); - }); - - it("returns neynar_error on 5xx from username lookup", async () => { - const { verifyFc } = await import("./activation-verify"); - mockFetch(500); - - const result = await verifyFc("testuser", PLOTLINK_FID); - expect(result).toEqual({ ok: false, error: "neynar_error" }); - }); - - it("returns neynar_error on 5xx from follow check", async () => { - const { verifyFc } = await import("./activation-verify"); - mockFetch(200, { user: { fid: 999 } }); - mockFetch(500); - - const result = await verifyFc("testuser", PLOTLINK_FID); - expect(result).toEqual({ ok: false, error: "neynar_error" }); - }); - - it("returns neynar_error on network failure", async () => { - const { verifyFc } = await import("./activation-verify"); - vi.mocked(fetch).mockRejectedValueOnce(new Error("network error")); - - const result = await verifyFc("testuser", PLOTLINK_FID); - expect(result).toEqual({ ok: false, error: "neynar_error" }); - }); - - it("returns neynar_error when NEYNAR_API_KEY is not set", async () => { - vi.stubEnv("NEYNAR_API_KEY", ""); - const { verifyFc } = await import("./activation-verify"); - - const result = await verifyFc("testuser", PLOTLINK_FID); - expect(result).toEqual({ ok: false, error: "neynar_error" }); - }); -}); diff --git a/lib/airdrop/activation-verify.ts b/lib/airdrop/activation-verify.ts deleted file mode 100644 index 4cf1d0a9..00000000 --- a/lib/airdrop/activation-verify.ts +++ /dev/null @@ -1,58 +0,0 @@ -const NEYNAR_BASE = "https://api.neynar.com/v2/farcaster"; - -type VerifyFcResult = - | { ok: true; fid: number } - | { ok: false; error: "user_not_found" | "not_following" | "neynar_error" }; - -export async function verifyFc( - username: string, - plotlinkFid: number, -): Promise { - const apiKey = process.env.NEYNAR_API_KEY; - if (!apiKey) return { ok: false, error: "neynar_error" }; - - let fid: number; - try { - const res = await fetch( - `${NEYNAR_BASE}/user/by_username?username=${encodeURIComponent(username)}`, - { headers: { accept: "application/json", "x-api-key": apiKey } }, - ); - - if (res.status === 404 || res.status === 400) { - return { ok: false, error: "user_not_found" }; - } - if (!res.ok) { - return { ok: false, error: "neynar_error" }; - } - - const data = await res.json(); - fid = data?.user?.fid; - if (!fid) return { ok: false, error: "user_not_found" }; - } catch { - return { ok: false, error: "neynar_error" }; - } - - try { - const res = await fetch( - `${NEYNAR_BASE}/user/bulk?fids=${plotlinkFid}&viewer_fid=${fid}`, - { headers: { accept: "application/json", "x-api-key": apiKey } }, - ); - - if (!res.ok) { - return { ok: false, error: "neynar_error" }; - } - - const data = await res.json(); - const target = data?.users?.[0]; - if (!target) return { ok: false, error: "neynar_error" }; - - const isFollowing = target?.viewer_context?.following ?? false; - if (!isFollowing) { - return { ok: false, error: "not_following" }; - } - - return { ok: true, fid }; - } catch { - return { ok: false, error: "neynar_error" }; - } -} diff --git a/lib/airdrop/award.ts b/lib/airdrop/award.ts deleted file mode 100644 index 9142fe49..00000000 --- a/lib/airdrop/award.ts +++ /dev/null @@ -1,5 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export async function awardWritePoints(writerAddress: string, storylineId: number, timestamp?: Date): Promise {} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export async function awardRatePoints(raterAddress: string, storylineId: number): Promise {} diff --git a/lib/airdrop/config.test.ts b/lib/airdrop/config.test.ts deleted file mode 100644 index 2c7fc9fa..00000000 --- a/lib/airdrop/config.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index c019165d..00000000 --- a/lib/airdrop/config.ts +++ /dev/null @@ -1,157 +0,0 @@ -export interface Milestone { - readonly mcap: number; - readonly pct: number; -} - -export interface AirdropConfig { - readonly CAMPAIGN_START: Date; - readonly CAMPAIGN_END: Date; - readonly POOL_AMOUNT: number; - readonly MILESTONES: { - readonly BRONZE: Milestone; - readonly SILVER: Milestone; - readonly GOLD: Milestone; - readonly DIAMOND: Milestone; - }; - readonly LOCKER_TX: string | null; - readonly POINTS: { - readonly BUY_PER_PLOT: number; - readonly REFERRAL_PCT: number; - readonly WRITE_FLAT: number; - readonly RATE_FLAT: number; - readonly RATE_DAILY_CAP: number; - }; - 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, - WRITE_FLAT: 50, - RATE_FLAT: 5, - RATE_DAILY_CAP: 10, -} as const; - -const STREAK_MIN_GAP_MINUTES = 30; - -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.PLOTLINK_FC_FID) || 0, - }; -} - -const PROD_CONFIG: AirdropConfig = { - 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 }, - }, - LOCKER_TX: null, - POINTS, - 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(), -}; - -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 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 = - getAirdropMode() === "prod" ? PROD_CONFIG : getAirdropConfig(new Date()); diff --git a/lib/airdrop/points.ts b/lib/airdrop/points.ts deleted file mode 100644 index b9a34580..00000000 --- a/lib/airdrop/points.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { getStreakBoost } from "./streak"; - -export { getStreakBoost }; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export function computeBuyPoints(plotSpent: number, currentStreak: number): number { - return plotSpent; -} diff --git a/lib/airdrop/siwe-verify.test.ts b/lib/airdrop/siwe-verify.test.ts deleted file mode 100644 index 78dce5a6..00000000 --- a/lib/airdrop/siwe-verify.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { SiweMessage } from "siwe"; -import { privateKeyToAccount } from "viem/accounts"; - -const TEST_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; -const account = privateKeyToAccount(TEST_KEY); - -beforeEach(() => { - vi.stubEnv("NEXT_PUBLIC_AIRDROP_MODE", ""); - vi.unstubAllEnvs(); - vi.stubEnv("NEXT_PUBLIC_AIRDROP_MODE", ""); -}); - -function buildMessage(overrides: Partial = {}): SiweMessage { - return new SiweMessage({ - domain: "plotlink.xyz", - address: account.address, - statement: "PlotLink Buy-Back Sprint activation", - uri: "https://plotlink.xyz/airdrop", - version: "1", - chainId: 8453, - nonce: "abcd1234", - issuedAt: new Date().toISOString(), - ...overrides, - }); -} - -async function signMessage(msg: SiweMessage): Promise { - const prepared = msg.prepareMessage(); - return account.signMessage({ message: prepared }); -} - -describe("verifySiweRequest", () => { - it("accepts a valid signature within freshness window", async () => { - const { verifySiweRequest } = await import("./siwe-verify"); - const msg = buildMessage(); - const sig = await signMessage(msg); - const result = await verifySiweRequest(msg.prepareMessage(), sig); - expect(result).toEqual({ ok: true, address: account.address.toLowerCase() }); - }); - - it("rejects an invalid signature", async () => { - const { verifySiweRequest } = await import("./siwe-verify"); - const msg = buildMessage(); - const badSig = "0x" + "ab".repeat(65); - const result = await verifySiweRequest(msg.prepareMessage(), badSig); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toBe("invalid_signature"); - }); - - it("rejects wrong domain", async () => { - const { verifySiweRequest } = await import("./siwe-verify"); - const msg = buildMessage({ domain: "evil.com" }); - const sig = await signMessage(msg); - const result = await verifySiweRequest(msg.prepareMessage(), sig); - expect(result).toEqual({ ok: false, error: "domain_mismatch" }); - }); - - it("rejects wrong URI", async () => { - const { verifySiweRequest } = await import("./siwe-verify"); - const msg = buildMessage({ uri: "https://evil.com/airdrop" }); - const sig = await signMessage(msg); - const result = await verifySiweRequest(msg.prepareMessage(), sig); - expect(result).toEqual({ ok: false, error: "uri_mismatch" }); - }); - - it("rejects wrong chain ID", async () => { - const { verifySiweRequest } = await import("./siwe-verify"); - const msg = buildMessage({ chainId: 1 }); - const sig = await signMessage(msg); - const result = await verifySiweRequest(msg.prepareMessage(), sig); - expect(result).toEqual({ ok: false, error: "chain_id_mismatch" }); - }); - - it("rejects wrong statement", async () => { - const { verifySiweRequest } = await import("./siwe-verify"); - const msg = buildMessage({ statement: "Wrong statement" }); - const sig = await signMessage(msg); - const result = await verifySiweRequest(msg.prepareMessage(), sig); - expect(result).toEqual({ ok: false, error: "statement_mismatch" }); - }); - - it("rejects expired signature (older than SIGNATURE_FRESHNESS_MIN)", async () => { - const { verifySiweRequest } = await import("./siwe-verify"); - const old = new Date(Date.now() - 15 * 60_000); - const msg = buildMessage({ issuedAt: old.toISOString() }); - const sig = await signMessage(msg); - const result = await verifySiweRequest(msg.prepareMessage(), sig); - expect(result).toEqual({ ok: false, error: "expired" }); - }); - - it("rejects signature issued in the future", async () => { - const { verifySiweRequest } = await import("./siwe-verify"); - const future = new Date(Date.now() + 5 * 60_000); - const msg = buildMessage({ issuedAt: future.toISOString() }); - const sig = await signMessage(msg); - const result = await verifySiweRequest(msg.prepareMessage(), sig); - expect(result).toEqual({ ok: false, error: "issued_in_future" }); - }); - - it("rejects message with past expirationTime", async () => { - const { verifySiweRequest } = await import("./siwe-verify"); - const past = new Date(Date.now() - 60_000); - const msg = buildMessage({ expirationTime: past.toISOString() }); - const sig = await signMessage(msg); - const result = await verifySiweRequest(msg.prepareMessage(), sig); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toBe("expired"); - }); - - it("rejects message without issuedAt", async () => { - const { verifySiweRequest } = await import("./siwe-verify"); - const rawMsg = [ - "plotlink.xyz wants you to sign in with your Ethereum account:", - account.address, - "", - "PlotLink Buy-Back Sprint activation", - "", - "URI: https://plotlink.xyz/airdrop", - "Version: 1", - "Chain ID: 8453", - "Nonce: abcd1234", - ].join("\n"); - const sig = await account.signMessage({ message: rawMsg }); - const result = await verifySiweRequest(rawMsg, sig); - expect(result.ok).toBe(false); - if (!result.ok) expect(["missing_issued_at", "invalid_message"]).toContain(result.error); - }); - - it("rejects unparseable message", async () => { - const { verifySiweRequest } = await import("./siwe-verify"); - const result = await verifySiweRequest("not a siwe message", "0x1234"); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toBe("invalid_message"); - }); -}); diff --git a/lib/airdrop/siwe-verify.ts b/lib/airdrop/siwe-verify.ts deleted file mode 100644 index 7d83bbc3..00000000 --- a/lib/airdrop/siwe-verify.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { SiweMessage } from "siwe"; -import { getAirdropConfig } from "./config"; - -export async function verifySiweRequest( - message: string, - signature: string, -): Promise<{ ok: true; address: string } | { ok: false; error: string }> { - const config = getAirdropConfig(); - - let parsed: SiweMessage; - try { - parsed = new SiweMessage(message); - } catch { - return { ok: false, error: "invalid_message" }; - } - - if (parsed.domain !== config.SIWE_DOMAIN) { - return { ok: false, error: "domain_mismatch" }; - } - if (parsed.uri !== config.SIWE_URI) { - return { ok: false, error: "uri_mismatch" }; - } - if (parsed.chainId !== config.SIWE_CHAIN_ID) { - return { ok: false, error: "chain_id_mismatch" }; - } - if (parsed.statement !== config.SIWE_STATEMENT) { - return { ok: false, error: "statement_mismatch" }; - } - - if (!parsed.issuedAt) { - return { ok: false, error: "missing_issued_at" }; - } - - const issuedAt = new Date(parsed.issuedAt); - const now = new Date(); - const ageMin = (now.getTime() - issuedAt.getTime()) / 60_000; - if (ageMin > config.SIGNATURE_FRESHNESS_MIN) { - return { ok: false, error: "expired" }; - } - if (ageMin < -1) { - return { ok: false, error: "issued_in_future" }; - } - - try { - const result = await parsed.verify( - { signature, domain: config.SIWE_DOMAIN }, - { suppressExceptions: true }, - ); - - if (!result.success) { - const errorType = result.error?.type ?? "unknown"; - if (errorType.toLowerCase().includes("signature")) { - return { ok: false, error: "invalid_signature" }; - } - if (errorType.toLowerCase().includes("expired")) { - return { ok: false, error: "expired" }; - } - return { ok: false, error: errorType }; - } - - return { ok: true, address: result.data.address.toLowerCase() }; - } catch { - return { ok: false, error: "invalid_signature" }; - } -} diff --git a/lib/airdrop/sql.test.ts b/lib/airdrop/sql.test.ts deleted file mode 100644 index 7d811221..00000000 --- a/lib/airdrop/sql.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -// @vitest-environment node -import { describe, expect, it, beforeAll, afterAll } from "vitest"; -import { PGlite } from "@electric-sql/pglite"; -import { weightedSpendQuery } from "./sql"; -import type { AirdropConfig } from "./config"; - -let db: PGlite; - -function buildConfig(overrides: Partial = {}): AirdropConfig { - return { - CAMPAIGN_START: new Date("2026-07-01T00:00:00Z"), - CAMPAIGN_END: new Date("2026-10-01T00:00:00Z"), - 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 }, - }, - LOCKER_TX: null, - POINTS: { BUY_PER_PLOT: 1, REFERRAL_PCT: 20, WRITE_FLAT: 50, RATE_FLAT: 5, RATE_DAILY_CAP: 10 }, - STREAK_BOOSTS: {}, - STREAK_MIN_GAP_MINUTES: 30, - MIN_REFERRAL_THRESHOLD: 50, - REFERRAL_MULTIPLIER_PER_REF: 0.2, - REFERRAL_MULTIPLIER_CAP: 3.0, - SIGNATURE_FRESHNESS_MIN: 10, - SIWE_DOMAIN: "plotlink.xyz", - SIWE_URI: "https://plotlink.xyz/airdrop", - SIWE_STATEMENT: "PlotLink Buy-Back Sprint activation", - SIWE_CHAIN_ID: 8453, - PLOTLINK_X_HANDLE: "plotlinkxyz", - PLOTLINK_FC_FID: 0, - CLAIM_WINDOW_DAYS: 30, - ...overrides, - }; -} - -beforeAll(async () => { - db = new PGlite(); - await db.exec(` - CREATE TABLE pl_activations ( - address TEXT PRIMARY KEY, - fid BIGINT, - activated_at TIMESTAMPTZ, - is_blacklisted BOOLEAN NOT NULL DEFAULT FALSE - ); - CREATE TABLE pl_points ( - id SERIAL PRIMARY KEY, - address TEXT NOT NULL, - action TEXT NOT NULL, - points NUMERIC NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW() - ); - CREATE TABLE pl_referrals ( - id SERIAL PRIMARY KEY, - referrer_address TEXT NOT NULL, - referred_address TEXT NOT NULL - ); - `); - const migrationSql = await import("fs").then(fs => - fs.readFileSync(new URL("../../supabase/migrations/00041_weighted_spend_function.sql", import.meta.url), "utf-8") - ); - await db.exec(migrationSql.replace(/GRANT[^;]*;/, "")); -}); - -afterAll(async () => { - await db.close(); -}); - -async function resetFixtures() { - await db.exec("DELETE FROM pl_referrals; DELETE FROM pl_points; DELETE FROM pl_activations;"); -} - -async function runQuery(config: AirdropConfig) { - const { sql, params } = weightedSpendQuery(config); - const result = await db.query(sql, params); - return result.rows as Array<{ - address: string; - buy_volume: number; - qualified_refs: number; - has_fc_bonus: number; - multiplier: number; - weighted_spend: number; - community_total: number; - }>; -} - -const IN_CAMPAIGN = "2026-08-01T00:00:00Z"; -const config = buildConfig(); - -describe("weightedSpendQuery against PGlite", () => { - it("100 PLOT + 2 qualified refs + FC bonus → multiplier 1.6, weighted 160", async () => { - await resetFixtures(); - await db.exec(` - INSERT INTO pl_activations (address, fid, activated_at) VALUES - ('alice', 123, '2026-07-01'), - ('ref1', NULL, '2026-07-01'), - ('ref2', NULL, '2026-07-01'); - INSERT INTO pl_points (address, action, points, created_at) VALUES - ('alice', 'buy', 100, '${IN_CAMPAIGN}'), - ('ref1', 'buy', 60, '${IN_CAMPAIGN}'), - ('ref2', 'buy', 80, '${IN_CAMPAIGN}'); - INSERT INTO pl_referrals (referrer_address, referred_address) VALUES - ('alice', 'ref1'), - ('alice', 'ref2'); - `); - - const rows = await runQuery(config); - const alice = rows.find(r => r.address === "alice")!; - - expect(Number(alice.buy_volume)).toBe(100); - expect(Number(alice.qualified_refs)).toBe(2); - expect(Number(alice.has_fc_bonus)).toBe(1); - expect(Number(alice.multiplier)).toBeCloseTo(1.6); - expect(Number(alice.weighted_spend)).toBeCloseTo(160); - }); - - it("excludes blacklisted wallets from results", async () => { - await resetFixtures(); - await db.exec(` - INSERT INTO pl_activations (address, activated_at, is_blacklisted) VALUES - ('good', '2026-07-01', FALSE), - ('bad', '2026-07-01', TRUE); - INSERT INTO pl_points (address, action, points, created_at) VALUES - ('good', 'buy', 100, '${IN_CAMPAIGN}'), - ('bad', 'buy', 100, '${IN_CAMPAIGN}'); - `); - - const rows = await runQuery(config); - expect(rows).toHaveLength(1); - expect(rows[0].address).toBe("good"); - }); - - it("excludes non-activated wallets from results", async () => { - await resetFixtures(); - await db.exec(` - INSERT INTO pl_activations (address, activated_at) VALUES - ('active', '2026-07-01'), - ('pending', NULL); - INSERT INTO pl_points (address, action, points, created_at) VALUES - ('active', 'buy', 100, '${IN_CAMPAIGN}'), - ('pending', 'buy', 100, '${IN_CAMPAIGN}'); - `); - - const rows = await runQuery(config); - expect(rows).toHaveLength(1); - expect(rows[0].address).toBe("active"); - }); - - it("qualified_refs excludes non-activated referees", async () => { - await resetFixtures(); - await db.exec(` - INSERT INTO pl_activations (address, activated_at) VALUES - ('alice', '2026-07-01'), - ('bob', '2026-07-01'), - ('charlie', NULL); - INSERT INTO pl_points (address, action, points, created_at) VALUES - ('alice', 'buy', 100, '${IN_CAMPAIGN}'), - ('bob', 'buy', 60, '${IN_CAMPAIGN}'), - ('charlie', 'buy', 60, '${IN_CAMPAIGN}'); - INSERT INTO pl_referrals (referrer_address, referred_address) VALUES - ('alice', 'bob'), - ('alice', 'charlie'); - `); - - const rows = await runQuery(config); - const alice = rows.find(r => r.address === "alice")!; - expect(Number(alice.qualified_refs)).toBe(1); - }); - - it("qualified_refs excludes blacklisted referees", async () => { - await resetFixtures(); - await db.exec(` - INSERT INTO pl_activations (address, activated_at, is_blacklisted) VALUES - ('alice', '2026-07-01', FALSE), - ('dave', '2026-07-01', TRUE); - INSERT INTO pl_points (address, action, points, created_at) VALUES - ('alice', 'buy', 100, '${IN_CAMPAIGN}'), - ('dave', 'buy', 60, '${IN_CAMPAIGN}'); - INSERT INTO pl_referrals (referrer_address, referred_address) VALUES - ('alice', 'dave'); - `); - - const rows = await runQuery(config); - const alice = rows.find(r => r.address === "alice")!; - expect(Number(alice.qualified_refs)).toBe(0); - }); - - it("qualified_refs excludes under-threshold referees (49 < 50)", async () => { - await resetFixtures(); - await db.exec(` - INSERT INTO pl_activations (address, activated_at) VALUES - ('alice', '2026-07-01'), - ('low', '2026-07-01'), - ('high', '2026-07-01'); - INSERT INTO pl_points (address, action, points, created_at) VALUES - ('alice', 'buy', 100, '${IN_CAMPAIGN}'), - ('low', 'buy', 49, '${IN_CAMPAIGN}'), - ('high', 'buy', 50, '${IN_CAMPAIGN}'); - INSERT INTO pl_referrals (referrer_address, referred_address) VALUES - ('alice', 'low'), - ('alice', 'high'); - `); - - const rows = await runQuery(config); - const alice = rows.find(r => r.address === "alice")!; - expect(Number(alice.qualified_refs)).toBe(1); - }); - - it("TEST vs PROD campaign windows produce different buy_volume", async () => { - await resetFixtures(); - await db.exec(` - INSERT INTO pl_activations (address, activated_at) VALUES ('alice', '2026-06-01'); - INSERT INTO pl_points (address, action, points, created_at) VALUES - ('alice', 'buy', 50, '2026-06-01T00:02:00Z'), - ('alice', 'buy', 75, '${IN_CAMPAIGN}'); - `); - - const testConfig = buildConfig({ - CAMPAIGN_START: new Date("2026-06-01T00:00:00Z"), - CAMPAIGN_END: new Date("2026-06-01T00:05:00Z"), - }); - - const testRows = await runQuery(testConfig); - const prodRows = await runQuery(config); - - expect(Number(testRows[0].buy_volume)).toBe(50); - expect(Number(prodRows[0].buy_volume)).toBe(75); - }); - - it("community_total sums all weighted_spend across rows", async () => { - await resetFixtures(); - await db.exec(` - INSERT INTO pl_activations (address, activated_at) VALUES - ('a', '2026-07-01'), - ('b', '2026-07-01'); - INSERT INTO pl_points (address, action, points, created_at) VALUES - ('a', 'buy', 100, '${IN_CAMPAIGN}'), - ('b', 'buy', 200, '${IN_CAMPAIGN}'); - `); - - const rows = await runQuery(config); - expect(Number(rows[0].community_total)).toBe(300); - expect(Number(rows[1].community_total)).toBe(300); - }); - - it("ref count input capped at 10 (11 refs same as 10)", async () => { - await resetFixtures(); - const refInserts = Array.from({ length: 11 }, (_, i) => - `('ref${i}', '2026-07-01')` - ).join(","); - const buyInserts = Array.from({ length: 11 }, (_, i) => - `('ref${i}', 'buy', 60, '${IN_CAMPAIGN}')` - ).join(","); - const relInserts = Array.from({ length: 11 }, (_, i) => - `('alice', 'ref${i}')` - ).join(","); - - await db.exec(` - INSERT INTO pl_activations (address, activated_at) VALUES - ('alice', '2026-07-01'), ${refInserts}; - INSERT INTO pl_points (address, action, points, created_at) VALUES - ('alice', 'buy', 100, '${IN_CAMPAIGN}'), ${buyInserts}; - INSERT INTO pl_referrals (referrer_address, referred_address) VALUES ${relInserts}; - `); - - const rows = await runQuery(config); - const alice = rows.find(r => r.address === "alice")!; - expect(Number(alice.qualified_refs)).toBe(11); - expect(Number(alice.multiplier)).toBe(3.0); - }); - - it("multiplier is capped at REFERRAL_MULTIPLIER_CAP", async () => { - await resetFixtures(); - const refInserts = Array.from({ length: 20 }, (_, i) => - `('ref${i}', '2026-07-01')` - ).join(","); - const buyInserts = Array.from({ length: 20 }, (_, i) => - `('ref${i}', 'buy', 60, '${IN_CAMPAIGN}')` - ).join(","); - const relInserts = Array.from({ length: 20 }, (_, i) => - `('whale', 'ref${i}')` - ).join(","); - - await db.exec(` - INSERT INTO pl_activations (address, activated_at) VALUES - ('whale', '2026-07-01'), ${refInserts}; - UPDATE pl_activations SET fid = 1 WHERE address = 'whale'; - INSERT INTO pl_points (address, action, points, created_at) VALUES - ('whale', 'buy', 100, '${IN_CAMPAIGN}'), ${buyInserts}; - INSERT INTO pl_referrals (referrer_address, referred_address) VALUES ${relInserts}; - `); - - const rows = await runQuery(config); - const whale = rows.find(r => r.address === "whale")!; - expect(Number(whale.multiplier)).toBe(3.0); - expect(Number(whale.weighted_spend)).toBeCloseTo(300); - }); -}); diff --git a/lib/airdrop/sql.ts b/lib/airdrop/sql.ts deleted file mode 100644 index 963e383d..00000000 --- a/lib/airdrop/sql.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { AirdropConfig } from "./config"; - -export interface WeightedSpendQuery { - sql: string; - params: (string | number)[]; -} - -export interface WeightedSpendRow { - address: string; - buy_volume: number; - qualified_refs: number; - has_fc_bonus: number; - multiplier: number; - weighted_spend: number; - community_total: number; -} - -export function weightedSpendQuery(config: AirdropConfig): WeightedSpendQuery { - const params: (string | number)[] = [ - config.CAMPAIGN_START.toISOString(), - config.CAMPAIGN_END.toISOString(), - config.MIN_REFERRAL_THRESHOLD, - config.REFERRAL_MULTIPLIER_PER_REF, - config.REFERRAL_MULTIPLIER_CAP, - ]; - - const sql = `SELECT * FROM weighted_spend($1::TIMESTAMPTZ, $2::TIMESTAMPTZ, $3::NUMERIC, $4::NUMERIC, $5::NUMERIC)`; - - return { sql, params }; -} diff --git a/lib/airdrop/streak.ts b/lib/airdrop/streak.ts deleted file mode 100644 index 506a23a4..00000000 --- a/lib/airdrop/streak.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Streak helpers (#882) - * - * Boost multiplier lookup, tier drop logic, and next-tier info. - */ - -import { AIRDROP_CONFIG } from "./config"; - -const TIER_THRESHOLDS = Object.keys(AIRDROP_CONFIG.STREAK_BOOSTS) - .map(Number) - .sort((a, b) => a - b); // ascending: [7, 14, 30, 50, 100] - -/** - * Look up the streak boost multiplier for a given streak length. - * Returns the highest qualifying boost (e.g. streak=15 → 0.10 for the 14-day tier). - */ -export function getStreakBoost(currentStreak: number): number { - for (let i = TIER_THRESHOLDS.length - 1; i >= 0; i--) { - if (currentStreak >= TIER_THRESHOLDS[i]) { - return AIRDROP_CONFIG.STREAK_BOOSTS[TIER_THRESHOLDS[i]]; - } - } - return 0; -} - -/** - * Drop one tier after a missed day. Returns the new streak value. - * Per spec: streak drops to the previous tier's threshold. - * 100+ → 50, 50-99 → 30, 30-49 → 14, 14-29 → 7, 7-13 → 0, 1-6 → 0 - */ -export function dropOneTier(streak: number): number { - // Find the current tier and drop to the one below it - for (let i = TIER_THRESHOLDS.length - 1; i >= 0; i--) { - if (streak >= TIER_THRESHOLDS[i]) { - return i > 0 ? TIER_THRESHOLDS[i - 1] : 0; - } - } - return 0; -} - -/** - * Get the next tier info, or null if already at max. - */ -export function getNextTier( - currentStreak: number, -): { days: number; boost: number } | null { - for (const threshold of TIER_THRESHOLDS) { - if (currentStreak < threshold) { - return { - days: threshold, - boost: AIRDROP_CONFIG.STREAK_BOOSTS[threshold], - }; - } - } - return null; // already at max tier -} diff --git a/lib/airdrop/twap.test.ts b/lib/airdrop/twap.test.ts deleted file mode 100644 index a0923168..00000000 --- a/lib/airdrop/twap.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateTwapSamples } from "./twap"; - -describe("validateTwapSamples", () => { - it("rejects 0 samples", () => { - const result = validateTwapSamples(0, false); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toContain("No daily price entries"); - }); - - it("rejects 1 sample without override", () => { - const result = validateTwapSamples(1, false); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toContain("requires >=5"); - }); - - it("rejects 4 samples without override", () => { - const result = validateTwapSamples(4, false); - expect(result.ok).toBe(false); - }); - - it("allows 1 sample with override", () => { - const result = validateTwapSamples(1, true); - expect(result.ok).toBe(true); - if (result.ok) expect(result.warning).toBeDefined(); - }); - - it("allows 5 samples with warning", () => { - const result = validateTwapSamples(5, false); - expect(result.ok).toBe(true); - if (result.ok) expect(result.warning).toContain("5 samples"); - }); - - it("allows 6 samples with warning", () => { - const result = validateTwapSamples(6, false); - expect(result.ok).toBe(true); - if (result.ok) expect(result.warning).toContain("6 samples"); - }); - - it("allows 7 samples cleanly (no warning)", () => { - const result = validateTwapSamples(7, false); - expect(result.ok).toBe(true); - if (result.ok) expect(result.warning).toBeUndefined(); - }); -}); diff --git a/lib/airdrop/twap.ts b/lib/airdrop/twap.ts deleted file mode 100644 index b4e12133..00000000 --- a/lib/airdrop/twap.ts +++ /dev/null @@ -1,27 +0,0 @@ -export const EXPECTED_SAMPLES = 7; -export const MINIMUM_SAMPLES = 5; -export const OVERRIDE_ENV = "AIRDROP_FINALIZE_ALLOW_PARTIAL_TWAP"; - -export function validateTwapSamples( - count: number, - overrideEnabled: boolean, -): { ok: true; warning?: string } | { ok: false; error: string } { - if (count === 0) { - return { ok: false, error: "No daily price entries found for TWAP window" }; - } - if (count < MINIMUM_SAMPLES && !overrideEnabled) { - return { - ok: false, - error: - `TWAP requires >=${MINIMUM_SAMPLES} daily samples, got ${count}. ` + - `Investigate pl_daily_prices cron coverage OR set ${OVERRIDE_ENV}=1 to proceed with partial data.`, - }; - } - if (count < EXPECTED_SAMPLES) { - return { - ok: true, - warning: `TWAP using ${count} samples (expected ${EXPECTED_SAMPLES}). Verify pl_daily_prices cron coverage.`, - }; - } - return { ok: true }; -} diff --git a/lib/airdrop/twitterapi.test.ts b/lib/airdrop/twitterapi.test.ts deleted file mode 100644 index a7a644a0..00000000 --- a/lib/airdrop/twitterapi.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; - -beforeEach(() => { - vi.stubEnv("TWITTERAPI_IO_KEY", "test-key"); - vi.stubGlobal("fetch", vi.fn()); -}); - -afterEach(async () => { - const { _clearCache } = await import("./twitterapi"); - _clearCache(); - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); -}); - -function mockFetchResponse(status: number, body?: unknown) { - return vi.mocked(fetch).mockResolvedValueOnce({ - ok: status >= 200 && status < 300, - status, - statusText: status === 404 ? "Not Found" : status >= 500 ? "Internal Server Error" : "OK", - json: () => Promise.resolve(body), - } as Response); -} - -const MOCK_USER_DATA = { - data: { - id: "12345", - name: "Test User", - profile_image_url: "https://pbs.twimg.com/photo.jpg", - public_metrics: { followers_count: 1500 }, - description: "Hello world bio", - }, -}; - -describe("lookupXUser", () => { - it("returns user data for a successful lookup", async () => { - const { lookupXUser } = await import("./twitterapi"); - mockFetchResponse(200, MOCK_USER_DATA); - - const result = await lookupXUser("testuser"); - expect(result).toEqual({ - display_name: "Test User", - avatar_url: "https://pbs.twimg.com/photo.jpg", - follower_count: 1500, - x_user_id: "12345", - bio_snippet: "Hello world bio", - }); - expect(fetch).toHaveBeenCalledWith( - "https://api.twitterapi.io/v2/users/by/username/testuser", - { headers: { "X-API-Key": "test-key" } }, - ); - }); - - it("returns null for 404", async () => { - const { lookupXUser } = await import("./twitterapi"); - mockFetchResponse(404); - - const result = await lookupXUser("nonexistent"); - expect(result).toBeNull(); - }); - - it("throws on 5xx", async () => { - const { lookupXUser } = await import("./twitterapi"); - mockFetchResponse(500); - - await expect(lookupXUser("testuser")).rejects.toThrow("twitterapi.io error: 500"); - }); - - it("uses cache on second call within TTL", async () => { - const { lookupXUser } = await import("./twitterapi"); - mockFetchResponse(200, MOCK_USER_DATA); - - const first = await lookupXUser("cached_user"); - const second = await lookupXUser("cached_user"); - - expect(first).toEqual(second); - expect(fetch).toHaveBeenCalledTimes(1); - }); - - it("caches null results (404)", async () => { - const { lookupXUser } = await import("./twitterapi"); - mockFetchResponse(404); - - await lookupXUser("missing"); - await lookupXUser("missing"); - - expect(fetch).toHaveBeenCalledTimes(1); - }); - - it("normalizes username to lowercase for cache key", async () => { - const { lookupXUser } = await import("./twitterapi"); - mockFetchResponse(200, MOCK_USER_DATA); - - await lookupXUser("TestUser"); - await lookupXUser("testuser"); - - expect(fetch).toHaveBeenCalledTimes(1); - }); - - it("throws when TWITTERAPI_IO_KEY is not set", async () => { - vi.stubEnv("TWITTERAPI_IO_KEY", ""); - const { lookupXUser } = await import("./twitterapi"); - - await expect(lookupXUser("test")).rejects.toThrow("TWITTERAPI_IO_KEY not configured"); - }); - - it("LRU: recently-hit key survives eviction past max entries", async () => { - const { lookupXUser } = await import("./twitterapi"); - - // Insert "old" + 199 fillers to fill cache to max (200) - mockFetchResponse(200, MOCK_USER_DATA); - await lookupXUser("old"); - for (let i = 0; i < 199; i++) { - mockFetchResponse(200, { - data: { ...MOCK_USER_DATA.data, id: String(1000 + i) }, - }); - await lookupXUser(`filler_${i}`); - } - expect(fetch).toHaveBeenCalledTimes(200); - - // Hit "old" to move it to most-recent position - await lookupXUser("old"); // cache hit, no fetch - expect(fetch).toHaveBeenCalledTimes(200); - - // Insert one more — evicts filler_0 (oldest), NOT "old" - mockFetchResponse(200, { - data: { ...MOCK_USER_DATA.data, id: "9999" }, - }); - await lookupXUser("new_entry"); - - // "old" should still be cached - const fetchCountBefore = vi.mocked(fetch).mock.calls.length; - await lookupXUser("old"); - expect(vi.mocked(fetch).mock.calls.length).toBe(fetchCountBefore); - - // filler_0 should have been evicted — triggers a new fetch - mockFetchResponse(200, { - data: { ...MOCK_USER_DATA.data, id: "1000" }, - }); - await lookupXUser("filler_0"); - expect(vi.mocked(fetch).mock.calls.length).toBe(fetchCountBefore + 1); - }); - - it("truncates bio to 200 chars", async () => { - const { lookupXUser } = await import("./twitterapi"); - const longBio = "a".repeat(300); - mockFetchResponse(200, { - data: { ...MOCK_USER_DATA.data, description: longBio }, - }); - - const result = await lookupXUser("longbio"); - expect(result!.bio_snippet).toHaveLength(200); - }); -}); diff --git a/lib/airdrop/twitterapi.ts b/lib/airdrop/twitterapi.ts deleted file mode 100644 index 5550d1ec..00000000 --- a/lib/airdrop/twitterapi.ts +++ /dev/null @@ -1,90 +0,0 @@ -const CACHE_TTL_MS = 5 * 60_000; -const CACHE_MAX = 200; - -interface XUser { - display_name: string; - avatar_url: string; - follower_count: number; - x_user_id: string; - bio_snippet: string; -} - -interface CacheEntry { - value: XUser | null; - expiry: number; -} - -const cache = new Map(); - -function evictExpired() { - const now = Date.now(); - for (const [key, entry] of cache) { - if (entry.expiry <= now) cache.delete(key); - } -} - -function cacheGet(key: string): { hit: true; value: XUser | null } | { hit: false } { - const entry = cache.get(key); - if (entry && entry.expiry > Date.now()) { - cache.delete(key); - cache.set(key, entry); - return { hit: true, value: entry.value }; - } - if (entry) cache.delete(key); - return { hit: false }; -} - -function cacheSet(key: string, value: XUser | null) { - if (cache.size >= CACHE_MAX) evictExpired(); - if (cache.size >= CACHE_MAX) { - const oldest = cache.keys().next().value!; - cache.delete(oldest); - } - cache.set(key, { value, expiry: Date.now() + CACHE_TTL_MS }); -} - -export async function lookupXUser(username: string): Promise { - const key = username.toLowerCase(); - - const cached = cacheGet(key); - if (cached.hit) return cached.value; - - const apiKey = process.env.TWITTERAPI_IO_KEY; - if (!apiKey) throw new Error("TWITTERAPI_IO_KEY not configured"); - - const res = await fetch( - `https://api.twitterapi.io/v2/users/by/username/${encodeURIComponent(key)}`, - { headers: { "X-API-Key": apiKey } }, - ); - - if (res.status === 404) { - cacheSet(key, null); - return null; - } - - if (!res.ok) { - throw new Error(`twitterapi.io error: ${res.status} ${res.statusText}`); - } - - const data = await res.json(); - const user = data?.data; - if (!user) { - cacheSet(key, null); - return null; - } - - const result: XUser = { - display_name: user.name ?? "", - avatar_url: user.profile_image_url ?? "", - follower_count: user.public_metrics?.followers_count ?? 0, - x_user_id: String(user.id), - bio_snippet: (user.description ?? "").slice(0, 200), - }; - - cacheSet(key, result); - return result; -} - -export function _clearCache() { - cache.clear(); -} diff --git a/lib/airdrop/verify-wallet.ts b/lib/airdrop/verify-wallet.ts deleted file mode 100644 index 1ee6ebb0..00000000 --- a/lib/airdrop/verify-wallet.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Shared SIWE wallet ownership verification (#883) - * - * Extracts address from a SIWE-style message and verifies the signature. - */ - -import { verifyMessage } from "viem"; - -/** - * Verify wallet ownership via signed message. - * Returns the verified lowercase address, or null if verification fails. - */ -export async function verifyWalletOwnership( - message: string, - signature: `0x${string}`, -): Promise { - // Parse address from SIWE message - const addressMatch = - message.match(/^(0x[a-fA-F0-9]{40})/m) ?? - message.match(/wants you to sign in with your Ethereum account:\n(0x[a-fA-F0-9]{40})/); - - if (!addressMatch) return null; - - const address = addressMatch[1].toLowerCase(); - - try { - const valid = await verifyMessage({ - address: address as `0x${string}`, - message, - signature, - }); - return valid ? address : null; - } catch { - return null; - } -} diff --git a/lib/supabase.ts b/lib/supabase.ts index 5b43ec21..948a34c7 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -352,228 +352,6 @@ export interface Database { }; Relationships: []; }; - pl_points: { - Row: { - id: string; - address: string; - action: string; - points: number; - metadata: Record | null; - created_at: string; - }; - Insert: { - id?: string; - address: string; - action: string; - points: number; - metadata?: Record | null; - created_at?: string; - }; - Update: { - id?: string; - address?: string; - action?: string; - points?: number; - metadata?: Record | null; - created_at?: string; - }; - Relationships: []; - }; - pl_referrals: { - Row: { - id: string; - referrer_address: string; - referred_address: string; - referral_code: string; - created_at: string; - }; - Insert: { - id?: string; - referrer_address: string; - referred_address: string; - referral_code: string; - created_at?: string; - }; - Update: { - id?: string; - referrer_address?: string; - referred_address?: string; - referral_code?: string; - created_at?: string; - }; - Relationships: []; - }; - pl_referral_codes: { - Row: { - address: string; - code: string; - is_farcaster_username: boolean; - }; - Insert: { - address: string; - code: string; - is_farcaster_username?: boolean; - }; - Update: { - address?: string; - code?: string; - is_farcaster_username?: boolean; - }; - Relationships: []; - }; - pl_streaks: { - Row: { - address: string; - current_streak: number; - last_checkin: string | null; - longest_streak: number; - }; - Insert: { - address: string; - current_streak?: number; - last_checkin?: string | null; - longest_streak?: number; - }; - Update: { - address?: string; - current_streak?: number; - last_checkin?: string | null; - longest_streak?: number; - }; - Relationships: []; - }; - pl_daily_prices: { - Row: { - id: number; - price_usd: number; - supply: number; - mcap_usd: number; - recorded_at: string; - }; - Insert: { - id?: never; - price_usd: number; - supply: number; - mcap_usd: number; - recorded_at?: string; - }; - Update: { - id?: never; - price_usd?: number; - supply?: number; - mcap_usd?: number; - recorded_at?: string; - }; - Relationships: []; - }; - pl_airdrop_proofs: { - Row: { - address: string; - amount: string; - proof: string; - merkle_root: string; - created_at: string; - updated_at: string; - }; - Insert: { - address: string; - amount: string; - proof: string; - merkle_root: string; - created_at?: string; - updated_at?: string; - }; - Update: { - address?: string; - amount?: string; - proof?: string; - merkle_root?: string; - created_at?: string; - updated_at?: string; - }; - Relationships: []; - }; - pl_weekly_snapshots: { - Row: { - id: number; - week_number: number; - week_start: string; - new_stories: number; - token_buys: number; - new_referrals: number; - mcap_start: number | null; - mcap_end: number | null; - total_pl_earned: number; - created_at: string; - }; - Insert: { - id?: never; - week_number: number; - week_start: string; - new_stories?: number; - token_buys?: number; - new_referrals?: number; - mcap_start?: number | null; - mcap_end?: number | null; - total_pl_earned?: number; - created_at?: string; - }; - Update: { - id?: never; - week_number?: number; - week_start?: string; - new_stories?: number; - token_buys?: number; - new_referrals?: number; - mcap_start?: number | null; - mcap_end?: number | null; - total_pl_earned?: number; - created_at?: string; - }; - 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; @@ -833,11 +611,3 @@ export type Rating = Database["public"]["Tables"]["ratings"]["Row"]; export type Comment = Database["public"]["Tables"]["comments"]["Row"]; export type TradeHistory = Database["public"]["Tables"]["trade_history"]["Row"]; export type User = Database["public"]["Tables"]["users"]["Row"]; -export type PlPoint = Database["public"]["Tables"]["pl_points"]["Row"]; -export type PlReferral = Database["public"]["Tables"]["pl_referrals"]["Row"]; -export type PlReferralCode = Database["public"]["Tables"]["pl_referral_codes"]["Row"]; -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-lock.json b/package-lock.json index 8a6c7e9e..5b1d5cda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.41.4", + "version": "1.42.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.41.4", + "version": "1.42.0", "workspaces": [ "packages/*" ], @@ -15,12 +15,10 @@ "@farcaster/miniapp-node": "^0.1.13", "@farcaster/miniapp-sdk": "^0.2.3", "@farcaster/miniapp-wagmi-connector": "^1.1.0", - "@openzeppelin/merkle-tree": "^1.0.8", "@rainbow-me/rainbowkit": "^2.2.10", "@supabase/supabase-js": "^2.99.1", "@tanstack/react-query": "^5.90.21", "@vercel/analytics": "^2.0.1", - "nanoid": "^5.1.9", "next": "16.2.6", "ox": "^0.14.8", "react": "19.2.3", @@ -29,12 +27,10 @@ "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", - "siwe": "^2.3.2", "viem": "^2.47.2", "wagmi": "^2.19.5" }, "devDependencies": { - "@electric-sql/pglite": "^0.4.6", "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", @@ -1701,13 +1697,6 @@ "@noble/ciphers": "^1.0.0" } }, - "node_modules/@electric-sql/pglite": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.6.tgz", - "integrity": "sha512-qmlmfN8UyKCee35qkV0r/MBp+Znl8FjBz7OpoglNvww3GJpw0/DLP0o1ZymvLNmcD5DTLOQdzKPtF8Hd3mdl1w==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -3327,19 +3316,6 @@ "@lit-labs/ssr-dom-shim": "^1.5.0" } }, - "node_modules/@metamask/abi-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@metamask/abi-utils/-/abi-utils-2.0.4.tgz", - "integrity": "sha512-StnIgUB75x7a7AgUhiaUZDpCsqGp7VkNnZh2XivXkJ6mPkE83U8ARGQj5MbRis7VJY8BC5V1AbB1fjdh0hupPQ==", - "license": "(Apache-2.0 AND MIT)", - "dependencies": { - "@metamask/superstruct": "^3.1.0", - "@metamask/utils": "^9.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/@metamask/eth-json-rpc-provider": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@metamask/eth-json-rpc-provider/-/eth-json-rpc-provider-1.0.1.tgz", @@ -3843,26 +3819,6 @@ "node": ">=16.0.0" } }, - "node_modules/@metamask/utils": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@metamask/utils/-/utils-9.3.0.tgz", - "integrity": "sha512-w8CVbdkDrVXFJbfBSlDfafDR6BAkpDmv1bC1UJVCoVny5tW2RKAdn9i68Xf7asYT4TnUhl/hN4zfUiKQq9II4g==", - "license": "ISC", - "dependencies": { - "@ethereumjs/tx": "^4.2.0", - "@metamask/superstruct": "^3.1.0", - "@noble/hashes": "^1.3.1", - "@scure/base": "^1.1.3", - "@types/debug": "^4.1.7", - "debug": "^4.3.4", - "pony-cause": "^2.1.10", - "semver": "^7.5.4", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -4119,16 +4075,6 @@ "node": ">=12.4.0" } }, - "node_modules/@openzeppelin/merkle-tree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@openzeppelin/merkle-tree/-/merkle-tree-1.0.8.tgz", - "integrity": "sha512-E2c9/Y3vjZXwVvPZKqCKUn7upnvam1P1ZhowJyZVQSkzZm5WhumtaRr+wkUXrZVfkIc7Gfrl7xzabElqDL09ow==", - "license": "MIT", - "dependencies": { - "@metamask/abi-utils": "^2.0.4", - "ethereum-cryptography": "^3.0.0" - } - }, "node_modules/@paulmillr/qr": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@paulmillr/qr/-/qr-0.2.1.tgz", @@ -8726,49 +8672,6 @@ "superstruct": "^2.0.2" } }, - "node_modules/@spruceid/siwe-parser": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@spruceid/siwe-parser/-/siwe-parser-2.1.2.tgz", - "integrity": "sha512-d/r3S1LwJyMaRAKQ0awmo9whfXeE88Qt00vRj91q5uv5ATtWIQEGJ67Yr5eSZw5zp1/fZCXZYuEckt8lSkereQ==", - "license": "Apache-2.0", - "dependencies": { - "@noble/hashes": "^1.1.2", - "apg-js": "^4.3.0", - "uri-js": "^4.4.1", - "valid-url": "^1.0.9" - } - }, - "node_modules/@stablelib/binary": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz", - "integrity": "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==", - "license": "MIT", - "dependencies": { - "@stablelib/int": "^1.0.1" - } - }, - "node_modules/@stablelib/int": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz", - "integrity": "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==", - "license": "MIT" - }, - "node_modules/@stablelib/random": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@stablelib/random/-/random-1.0.2.tgz", - "integrity": "sha512-rIsE83Xpb7clHPVRlBj8qNe5L8ISQOzjghYQm/dZ7VaM2KHYwMW5adjQjrzTZCchFnNCNhkwtnOBa9HTMJCI8w==", - "license": "MIT", - "dependencies": { - "@stablelib/binary": "^1.0.1", - "@stablelib/wipe": "^1.0.1" - } - }, - "node_modules/@stablelib/wipe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-1.0.1.tgz", - "integrity": "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==", - "license": "MIT" - }, "node_modules/@supabase/auth-js": { "version": "2.104.1", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.104.1.tgz", @@ -10966,13 +10869,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/aes-js": { - "version": "4.0.0-beta.5", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", - "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", - "license": "MIT", - "peer": true - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -11055,12 +10951,6 @@ "node": ">= 8" } }, - "node_modules/apg-js": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/apg-js/-/apg-js-4.4.0.tgz", - "integrity": "sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q==", - "license": "BSD-2-Clause" - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -13432,146 +13322,6 @@ "fast-safe-stringify": "^2.0.6" } }, - "node_modules/ethereum-cryptography": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-3.2.0.tgz", - "integrity": "sha512-Urr5YVsalH+Jo0sYkTkv1MyI9bLYZwW8BENZCeE1QYaTHETEYx0Nv/SVsWkSqpYrzweg6d8KMY1wTjH/1m/BIg==", - "license": "MIT", - "dependencies": { - "@noble/ciphers": "1.3.0", - "@noble/curves": "1.9.0", - "@noble/hashes": "1.8.0", - "@scure/bip32": "1.7.0", - "@scure/bip39": "1.6.0" - }, - "engines": { - "node": "^14.21.3 || >=16", - "npm": ">=9" - } - }, - "node_modules/ethereum-cryptography/node_modules/@noble/curves": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.0.tgz", - "integrity": "sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ethers": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", - "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/ethers-io/" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@adraffy/ens-normalize": "1.10.1", - "@noble/curves": "1.2.0", - "@noble/hashes": "1.3.2", - "@types/node": "22.7.5", - "aes-js": "4.0.0-beta.5", - "tslib": "2.7.0", - "ws": "8.17.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/ethers/node_modules/@adraffy/ens-normalize": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", - "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", - "license": "MIT", - "peer": true - }, - "node_modules/ethers/node_modules/@noble/curves": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", - "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@noble/hashes": "1.3.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ethers/node_modules/@noble/hashes": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ethers/node_modules/@types/node": { - "version": "22.7.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", - "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "node_modules/ethers/node_modules/tslib": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", - "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "license": "0BSD", - "peer": true - }, - "node_modules/ethers/node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "license": "MIT", - "peer": true - }, - "node_modules/ethers/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/eventemitter2": { "version": "6.4.9", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", @@ -16842,24 +16592,6 @@ "thenify-all": "^1.0.0" } }, - "node_modules/nanoid": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz", - "integrity": "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } - }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -18034,6 +17766,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -18955,21 +18688,6 @@ "dev": true, "license": "ISC" }, - "node_modules/siwe": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/siwe/-/siwe-2.3.2.tgz", - "integrity": "sha512-aSf+6+Latyttbj5nMu6GF3doMfv2UYj83hhwZgUF20ky6fTS83uVhkQABdIVnEuS8y1bBdk7p6ltb9SmlhTTlA==", - "license": "Apache-2.0", - "dependencies": { - "@spruceid/siwe-parser": "^2.1.2", - "@stablelib/random": "^1.0.1", - "uri-js": "^4.4.1", - "valid-url": "^1.0.9" - }, - "peerDependencies": { - "ethers": "^5.6.8 || ^6.0.8" - } - }, "node_modules/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -20237,6 +19955,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -20340,11 +20059,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/valid-url": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", - "integrity": "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==" - }, "node_modules/valtio": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/valtio/-/valtio-1.13.2.tgz", diff --git a/package.json b/package.json index 551d2369..094a1c8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.41.4", + "version": "1.42.0", "private": true, "workspaces": [ "packages/*" @@ -19,12 +19,10 @@ "@farcaster/miniapp-node": "^0.1.13", "@farcaster/miniapp-sdk": "^0.2.3", "@farcaster/miniapp-wagmi-connector": "^1.1.0", - "@openzeppelin/merkle-tree": "^1.0.8", "@rainbow-me/rainbowkit": "^2.2.10", "@supabase/supabase-js": "^2.99.1", "@tanstack/react-query": "^5.90.21", "@vercel/analytics": "^2.0.1", - "nanoid": "^5.1.9", "next": "16.2.6", "ox": "^0.14.8", "react": "19.2.3", @@ -33,12 +31,10 @@ "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", - "siwe": "^2.3.2", "viem": "^2.47.2", "wagmi": "^2.19.5" }, "devDependencies": { - "@electric-sql/pglite": "^0.4.6", "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", diff --git a/scripts/airdrop-finalize.ts b/scripts/airdrop-finalize.ts deleted file mode 100644 index 58b41657..00000000 --- a/scripts/airdrop-finalize.ts +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * Airdrop finalize script (v5) - * - * 1. Compute 7-day TWAP from pl_daily_prices - * 2. Determine milestone tier → released_pool - * 3. Call weighted_spend helper for per-wallet shares - * 4. Generate Merkle tree + proofs - * 5. Output root hash for T6.2 contract deploy - * - * Usage: - * npx tsx scripts/airdrop-finalize.ts [--dry-run] - * - * Requires: NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY - */ - -import { createClient } from "@supabase/supabase-js"; -import { parseUnits } from "viem"; -import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; -import { writeFileSync } from "fs"; -import { getAirdropConfig } from "../lib/airdrop/config"; -import { weightedSpendQuery } from "../lib/airdrop/sql"; -import { validateTwapSamples, OVERRIDE_ENV } from "../lib/airdrop/twap"; - -const dryRun = process.argv.includes("--dry-run"); - -const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL || ""; -const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || ""; - -if (!SUPABASE_URL || !SUPABASE_KEY) { - console.error("Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY"); - process.exit(1); -} - -const supabase = createClient(SUPABASE_URL, SUPABASE_KEY); -const config = getAirdropConfig(); - -async function computeTwap(): Promise { - const endDate = config.CAMPAIGN_END; - const startDate = new Date(endDate.getTime() - 7 * 86400000); - - const { data, error } = await supabase - .from("pl_daily_prices") - .select("mcap_usd") - .gte("recorded_at", startDate.toISOString().slice(0, 10)) - .lte("recorded_at", endDate.toISOString().slice(0, 10)); - - if (error) throw new Error(`Failed to fetch daily prices: ${error.message}`); - - const validation = validateTwapSamples( - data?.length ?? 0, - process.env[OVERRIDE_ENV] === "1", - ); - if (!validation.ok) throw new Error(validation.error); - if (validation.warning) console.warn(`Warning: ${validation.warning}`); - - const sum = data.reduce((acc, row) => acc + Number(row.mcap_usd), 0); - const twap = sum / data.length; - console.log(`TWAP (${data.length} days): $${twap.toLocaleString()}`); - return twap; -} - -function determineMilestone(twapMcap: number): { tier: string; pct: number } { - const { MILESTONES } = config; - if (twapMcap >= MILESTONES.DIAMOND.mcap) return { tier: "Diamond", pct: MILESTONES.DIAMOND.pct }; - if (twapMcap >= MILESTONES.GOLD.mcap) return { tier: "Gold", pct: MILESTONES.GOLD.pct }; - if (twapMcap >= MILESTONES.SILVER.mcap) return { tier: "Silver", pct: MILESTONES.SILVER.pct }; - if (twapMcap >= MILESTONES.BRONZE.mcap) return { tier: "Bronze", pct: MILESTONES.BRONZE.pct }; - return { tier: "None", pct: 0 }; -} - -async function fetchWeightedSpend() { - const { params } = weightedSpendQuery(config); - const [campStart, campEnd, minThreshold, perRef, cap] = params; - - const { data, error } = await supabase.rpc("weighted_spend", { - p_campaign_start: String(campStart), - p_campaign_end: String(campEnd), - p_min_referral_threshold: Number(minThreshold), - p_multiplier_per_ref: Number(perRef), - p_multiplier_cap: Number(cap), - }); - - if (error) throw new Error(`weighted_spend RPC failed: ${error.message}`); - return (data ?? []) as Array<{ - address: string; - weighted_spend: number; - community_total: number; - }>; -} - -function computeDistribution( - rows: Array<{ address: string; weighted_spend: number; community_total: number }>, - releasedPool: number, -): { address: string; amount: bigint }[] { - const communityTotal = Number(rows[0]?.community_total ?? 0); - if (communityTotal <= 0) return []; - - const totalWei = parseUnits(releasedPool.toString(), 18); - const totalBig = BigInt(Math.round(communityTotal * 1e6)); - - const entries: { address: string; floor: bigint; remainder: bigint }[] = []; - let floorSum = BigInt(0); - - for (const row of rows) { - const ws = Number(row.weighted_spend); - if (ws <= 0) continue; - const wsBig = BigInt(Math.round(ws * 1e6)); - const floor = (totalWei * wsBig) / totalBig; - const remainder = (totalWei * wsBig) % totalBig; - entries.push({ address: row.address, floor, remainder }); - floorSum += floor; - } - - let leftover = totalWei - floorSum; - entries.sort((a, b) => (b.remainder > a.remainder ? 1 : b.remainder < a.remainder ? -1 : 0)); - for (const entry of entries) { - if (leftover <= BigInt(0)) break; - entry.floor += BigInt(1); - leftover -= BigInt(1); - } - - return entries - .filter(e => e.floor > BigInt(0)) - .map(e => ({ address: e.address, amount: e.floor })) - .sort((a, b) => (b.amount > a.amount ? 1 : b.amount < a.amount ? -1 : 0)); -} - -function generateMerkleTree(distribution: { address: string; amount: bigint }[]) { - const values = distribution.map(d => [d.address, d.amount.toString()]); - const tree = StandardMerkleTree.of(values, ["address", "uint256"]); - - const proofs: Record = {}; - for (const [i, v] of tree.entries()) { - proofs[v[0] as string] = { amount: v[1] as string, proof: tree.getProof(i) }; - } - - return { root: tree.root, proofs }; -} - -async function storeProofs( - proofs: Record, - root: string, - tier: string, - twap: number, -) { - const output = { - generatedAt: new Date().toISOString(), - twapMcap: twap, - milestone: tier, - merkleRoot: root, - totalRecipients: Object.keys(proofs).length, - proofs, - }; - - writeFileSync("scripts/airdrop-proofs.json", JSON.stringify(output, null, 2)); - console.log("Proofs written to scripts/airdrop-proofs.json"); - - if (dryRun) { - console.log("[DRY RUN] Skipping DB writes"); - return; - } - - const entries = Object.entries(proofs).map(([address, { amount, proof }]) => ({ - address, - amount, - proof: JSON.stringify(proof), - merkle_root: root, - })); - - for (let i = 0; i < entries.length; i += 100) { - const batch = entries.slice(i, i + 100); - const { error } = await supabase.from("pl_airdrop_proofs").upsert(batch, { onConflict: "address" }); - if (error) throw new Error(`Failed to store proofs batch ${i}: ${error.message}`); - } - - console.log(`${entries.length} proofs stored in pl_airdrop_proofs`); -} - -async function main() { - console.log(`=== PLOT Airdrop Finalization${dryRun ? " [DRY RUN]" : ""} ===\n`); - - const twap = await computeTwap(); - const { tier, pct } = determineMilestone(twap); - console.log(`\nMilestone: ${tier} (${pct}%)`); - - if (pct === 0) { - console.log("\n100% burn, 0 eligible. Sub-Bronze FDV — no Merkle deploy needed."); - console.log(`Burn amount: ${config.POOL_AMOUNT} PLOT`); - return; - } - - const releasedPool = config.POOL_AMOUNT * (pct / 100); - console.log(`Released pool: ${releasedPool.toLocaleString()} PLOT`); - - const rows = await fetchWeightedSpend(); - if (rows.length === 0) { - console.log("\nZero total weighted spend — no activated wallets bought. Full burn."); - return; - } - - const distribution = computeDistribution(rows, releasedPool); - if (distribution.length === 0) { - console.log("\nNo eligible participants after distribution. Full burn."); - return; - } - - console.log(`\naddress,plot_share_wei`); - for (const d of distribution) { - console.log(`${d.address},${d.amount.toString()}`); - } - - const { root, proofs } = generateMerkleTree(distribution); - console.log(`\nMerkle root: ${root}`); - - await storeProofs(proofs, root, tier, twap); - - const burnPct = 100 - pct; - const burnAmount = config.POOL_AMOUNT * (burnPct / 100); - console.log("\n=== Summary ==="); - console.log(`TWAP MCap: $${twap.toLocaleString()}`); - console.log(`Milestone: ${tier} (${pct}% distributed)`); - console.log(`Distribute: ${releasedPool.toLocaleString()} PLOT to ${distribution.length} addresses`); - console.log(`Burn: ${burnAmount.toLocaleString()} PLOT (${burnPct}%)`); - console.log(`Merkle root: ${root}`); - console.log(`\nNext: deploy MerkleClaim contract with this root.`); -} - -main().catch((err) => { - console.error("Finalization failed:", err); - process.exit(1); -}); diff --git a/src/app/airdrop/AirdropStateMachine.tsx b/src/app/airdrop/AirdropStateMachine.tsx deleted file mode 100644 index 5607f800..00000000 --- a/src/app/airdrop/AirdropStateMachine.tsx +++ /dev/null @@ -1,159 +0,0 @@ -"use client"; - -import { useEffect, useState, useCallback, useRef } from "react"; -import { useAccount } from "wagmi"; -import { useConnectModal } from "@rainbow-me/rainbowkit"; -import { CampaignHero } from "../../components/airdrop/CampaignHero"; -import { ActivationFlow } from "../../components/airdrop/ActivationFlow"; -import { ContributionPanel } from "../../components/airdrop/ContributionPanel"; -import { ReferralCTA } from "../../components/airdrop/ReferralCTA"; -import { MilestoneClimb } from "../../components/airdrop/MilestoneClimb"; -import { ClaimCard } from "../../components/airdrop/ClaimCard"; -import { ClaimPanel } from "../../components/airdrop/ClaimPanel"; - -type AirdropState = - | "paused" - | "pre-activation" - | "mining" - | "settlement-normal" - | "settlement-final-burn"; - -const IS_PAUSED = process.env.NEXT_PUBLIC_AIRDROP_PAUSED === "1"; -const MERKLE_CLAIM_ADDRESS = process.env.NEXT_PUBLIC_MERKLE_CLAIM_ADDRESS; -const FINAL_BURN_TX = process.env.NEXT_PUBLIC_AIRDROP_FINAL_BURN_TX; -const FINAL_STATE = process.env.NEXT_PUBLIC_AIRDROP_FINAL_STATE as "sub_bronze" | "zero_recipient" | undefined; - -function deriveState(isConnected: boolean, activatedAt: string | null): AirdropState { - if (IS_PAUSED) return "paused"; - if (MERKLE_CLAIM_ADDRESS) return "settlement-normal"; - if (FINAL_BURN_TX) return "settlement-final-burn"; - if (!isConnected || !activatedAt) return "pre-activation"; - return "mining"; -} - -const needsFetch = !IS_PAUSED && !MERKLE_CLAIM_ADDRESS && !FINAL_BURN_TX; - -function HeroBlock({ isConnected, onActivate }: { isConnected: boolean; onActivate: () => void }) { - const { openConnectModal } = useConnectModal(); - - return ( -
-

- The airdrop pool
- grows with us.
- Or it burns. -

-

Buy storyline tokens · Bring friends · Push PLOT together

- {!isConnected ? ( - - ) : ( - - )} -

One signature + a few clicks. ~2 min.

-
- ); -} - -export function AirdropStateMachine() { - const { address, isConnected } = useAccount(); - const [fetchResult, setFetchResult] = useState<{ activatedAt: string | null; done: boolean }>({ activatedAt: null, done: !needsFetch }); - const activationRef = useRef(null); - - const onActivated = useCallback(() => { - setFetchResult({ activatedAt: new Date().toISOString(), done: true }); - }, []); - - const scrollToActivation = useCallback(() => { - activationRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); - }, []); - - useEffect(() => { - if (!needsFetch || !isConnected || !address) return; - - let cancelled = false; - fetch(`/api/airdrop/activation-status?address=${address.toLowerCase()}`) - .then(res => res.json()) - .then(data => { if (!cancelled) setFetchResult({ activatedAt: data.activated_at ?? null, done: true }); }) - .catch(() => { if (!cancelled) setFetchResult({ activatedAt: null, done: true }); }); - return () => { cancelled = true; setFetchResult({ activatedAt: null, done: false }); }; - }, [isConnected, address]); - - const activatedAt = (needsFetch && isConnected) ? fetchResult.activatedAt : null; - const loading = needsFetch && isConnected && !fetchResult.done; - const state = deriveState(isConnected, activatedAt); - - if (state === "paused") { - return ( -
-

Campaign Paused

-

Campaign temporarily paused. Will resume shortly.

-
- ); - } - - if (state === "settlement-normal") { - return ( - <> - -
- - ); - } - - if (state === "settlement-final-burn") { - return ( - <> - -
- - ); - } - - if (loading) { - return ( - <> - -

Loading...

- - ); - } - - if (state === "pre-activation") { - return ( - <> - - - {isConnected && ( -
- -
- )} -
- -
- - ); - } - - // mining - return ( - <> - -
- - - -
- - ); -} diff --git a/src/app/airdrop/page.tsx b/src/app/airdrop/page.tsx deleted file mode 100644 index 769d75b9..00000000 --- a/src/app/airdrop/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { Metadata } from "next"; -import { AirdropStateMachine } from "./AirdropStateMachine"; - -export const metadata: Metadata = { - title: "PlotLink Buy-Back Sprint | PlotLink", - description: "Activate, trade, and earn your share of the PLOT airdrop pool.", -}; - -export default function AirdropPage() { - return ( -
- -
- ); -} diff --git a/src/app/api/airdrop/activation-status/route.test.ts b/src/app/api/airdrop/activation-status/route.test.ts deleted file mode 100644 index f3bd56c8..00000000 --- a/src/app/api/airdrop/activation-status/route.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -// @vitest-environment node -import { describe, expect, it, vi } from "vitest"; - -const mockSingle = vi.fn(); - -vi.mock("../../../../../lib/supabase", () => ({ - createServerClient: () => ({ - from: () => ({ - select: () => ({ eq: () => ({ single: mockSingle }) }), - }), - }), -})); - -import { GET } from "./route"; - -function makeReq(address?: string) { - const url = address - ? `http://localhost/api/airdrop/activation-status?address=${address}` - : "http://localhost/api/airdrop/activation-status"; - return new Request(url); -} - -describe("GET /api/airdrop/activation-status", () => { - it("returns 4 fields for activated wallet", async () => { - mockSingle.mockResolvedValue({ - data: { - x_handle_confirmed_at: "2026-07-01", - x_follow_at: "2026-07-02", - fc_verified_at: "2026-07-03", - activated_at: "2026-07-02", - }, - }); - const res = await GET(makeReq("0xabc")); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.x_handle_confirmed_at).toBe("2026-07-01"); - expect(data.activated_at).toBe("2026-07-02"); - }); - - it("returns all-null for non-existent address", async () => { - mockSingle.mockResolvedValue({ data: null }); - const res = await GET(makeReq("0xnonexistent")); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data).toEqual({ - x_handle_confirmed_at: null, - x_follow_at: null, - fc_verified_at: null, - activated_at: null, - }); - }); - - it("includes Cache-Control: no-store header", async () => { - mockSingle.mockResolvedValue({ data: null }); - const res = await GET(makeReq("0xabc")); - expect(res.headers.get("Cache-Control")).toBe("no-store"); - }); - - 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/activation-status/route.ts b/src/app/api/airdrop/activation-status/route.ts deleted file mode 100644 index f5283d2e..00000000 --- a/src/app/api/airdrop/activation-status/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextResponse } from "next/server"; -import { createServerClient } from "../../../../../lib/supabase"; - -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 { data } = await supabase - .from("pl_activations") - .select("x_handle_confirmed_at, x_follow_at, fc_verified_at, activated_at") - .eq("address", address) - .single(); - - return NextResponse.json( - { - x_handle_confirmed_at: data?.x_handle_confirmed_at ?? null, - x_follow_at: data?.x_follow_at ?? null, - fc_verified_at: data?.fc_verified_at ?? null, - activated_at: data?.activated_at ?? null, - }, - { headers: { "Cache-Control": "no-store" } }, - ); -} diff --git a/src/app/api/airdrop/checkin/route.ts b/src/app/api/airdrop/checkin/route.ts deleted file mode 100644 index 160d08db..00000000 --- a/src/app/api/airdrop/checkin/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -export async function POST() { - return new Response(null, { status: 410 }); -} diff --git a/src/app/api/airdrop/confirm-x-handle/route.test.ts b/src/app/api/airdrop/confirm-x-handle/route.test.ts deleted file mode 100644 index cf79c32a..00000000 --- a/src/app/api/airdrop/confirm-x-handle/route.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -// @vitest-environment node -import { describe, expect, it, vi, beforeEach } from "vitest"; - -const mockUpsert = vi.fn().mockReturnValue({ error: null }); -const mockInsert = vi.fn().mockReturnValue({ error: null }); -const mockSelect = vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ limit: vi.fn().mockReturnValue({ single: vi.fn().mockResolvedValue({ data: null }) }) }), -}); - -vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({ - verifySiweRequest: vi.fn(), -})); -vi.mock("../../../../../lib/airdrop/twitterapi", () => ({ - lookupXUser: vi.fn(), -})); -vi.mock("../../../../../lib/supabase", () => ({ - createServerClient: () => ({ - from: (table: string) => { - if (table === "pl_activations") return { upsert: mockUpsert }; - if (table === "pl_referral_codes") return { select: mockSelect, insert: mockInsert }; - return {}; - }, - }), -})); -vi.mock("nanoid", () => ({ nanoid: () => "test1234" })); - -import { POST } from "./route"; -import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; -import { lookupXUser } from "../../../../../lib/airdrop/twitterapi"; - -function makeReq(body: unknown) { - return new Request("http://localhost/api/airdrop/confirm-x-handle", { - method: "POST", - body: JSON.stringify(body), - headers: { "content-type": "application/json" }, - }); -} - -beforeEach(() => { vi.clearAllMocks(); mockUpsert.mockReturnValue({ error: null }); }); - -describe("POST /api/airdrop/confirm-x-handle", () => { - it("returns 401 on invalid signature", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "invalid_signature" }); - const res = await POST(makeReq({ message: "m", signature: "s", username: "u" })); - expect(res.status).toBe(401); - }); - - it("server re-lookups username (ignores client x_user_id)", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); - vi.mocked(lookupXUser).mockResolvedValue({ - display_name: "Real", avatar_url: "url", follower_count: 50, x_user_id: "server_id", bio_snippet: "bio", - }); - - const res = await POST(makeReq({ message: "m", signature: "s", username: "test", x_user_id: "bogus_id" })); - expect(res.status).toBe(200); - expect(mockUpsert).toHaveBeenCalledWith( - expect.objectContaining({ x_user_id: "server_id" }), - expect.anything(), - ); - }); - - it("returns 404 for nonexistent X user", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); - vi.mocked(lookupXUser).mockResolvedValue(null); - - const res = await POST(makeReq({ message: "m", signature: "s", username: "ghost" })); - expect(res.status).toBe(404); - expect(mockUpsert).not.toHaveBeenCalled(); - }); - - it("returns 409 on UNIQUE conflict", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); - vi.mocked(lookupXUser).mockResolvedValue({ - display_name: "Test", avatar_url: "url", follower_count: 50, x_user_id: "123", bio_snippet: "bio", - }); - mockUpsert.mockReturnValue({ error: { code: "23505", message: "unique violation" } }); - - const res = await POST(makeReq({ message: "m", signature: "s", username: "taken" })); - expect(res.status).toBe(409); - }); - - it("R16: returns 503 when twitterapi.io throws (no stuck state)", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); - vi.mocked(lookupXUser).mockRejectedValue(new Error("network error")); - - const res = await POST(makeReq({ message: "m", signature: "s", username: "downuser" })); - expect(res.status).toBe(503); - expect(mockUpsert).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/airdrop/confirm-x-handle/route.ts b/src/app/api/airdrop/confirm-x-handle/route.ts deleted file mode 100644 index 9c0203fb..00000000 --- a/src/app/api/airdrop/confirm-x-handle/route.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { NextResponse } from "next/server"; -import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; -import { lookupXUser } from "../../../../../lib/airdrop/twitterapi"; -import { createServerClient } from "../../../../../lib/supabase"; -import { nanoid } from "nanoid"; - -export async function POST(req: Request) { - let message: string, signature: string, username: string; - try { - const body = await req.json(); - message = body.message; - signature = body.signature; - username = body.username; - if (!message || !signature || !username) throw new Error(); - } catch { - return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); - } - - const auth = await verifySiweRequest(message, signature); - if (!auth.ok) { - return NextResponse.json({ error: auth.error }, { status: 401 }); - } - - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - const address = auth.address; - const handle = username.toLowerCase(); - - let xUserId: string | null = null; - let confirmedAt: string | null = null; - - try { - const user = await lookupXUser(username); - if (!user) { - return NextResponse.json({ error: "X user not found" }, { status: 404 }); - } - xUserId = user.x_user_id; - confirmedAt = new Date().toISOString(); - } catch { - return NextResponse.json( - { error: "X verification temporarily unavailable. Please retry shortly." }, - { status: 503 }, - ); - } - - const { error: upsertErr } = await supabase - .from("pl_activations") - .upsert( - { - address, - x_handle: handle, - x_user_id: xUserId, - x_handle_confirmed_at: confirmedAt, - }, - { onConflict: "address" }, - ); - - if (upsertErr) { - if (upsertErr.code === "23505") { - return NextResponse.json({ error: "X handle already claimed by another wallet" }, { status: 409 }); - } - console.error("[confirm-x-handle] Upsert failed:", upsertErr.message); - return NextResponse.json({ error: "Failed to confirm handle" }, { status: 500 }); - } - - const { data: existingCode } = await supabase - .from("pl_referral_codes") - .select("code") - .eq("address", address) - .limit(1) - .single(); - - if (!existingCode) { - const code = nanoid(8); - await supabase.from("pl_referral_codes").insert({ - address, - code, - is_farcaster_username: false, - }); - } - - return NextResponse.json({ - address, - x_handle: handle, - confirmed: confirmedAt !== null, - }); -} diff --git a/src/app/api/airdrop/daily-prices/route.ts b/src/app/api/airdrop/daily-prices/route.ts deleted file mode 100644 index 3e6644ed..00000000 --- a/src/app/api/airdrop/daily-prices/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Daily FDV history for the campaign timeline chart (#936) - * GET /api/airdrop/daily-prices — no auth required - * - * Returns array of { date, fdv } ordered by date ascending. - */ - -import { NextResponse } from "next/server"; -import { createServerClient } from "../../../../../lib/supabase"; - -export async function GET() { - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - const { data, error } = await supabase - .from("pl_daily_prices") - .select("recorded_at, mcap_usd") - .order("recorded_at", { ascending: true }); - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); - } - - const points = (data ?? []).map((row) => ({ - date: row.recorded_at, - fdv: Number(row.mcap_usd), - })); - - return NextResponse.json(points, { - headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=60" }, - }); -} diff --git a/src/app/api/airdrop/leaderboard/route.test.ts b/src/app/api/airdrop/leaderboard/route.test.ts deleted file mode 100644 index d58ff9d6..00000000 --- a/src/app/api/airdrop/leaderboard/route.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -// @vitest-environment node -import { describe, expect, it, vi } from "vitest"; -import { NextRequest } from "next/server"; - -const mockRpc = vi.fn(); -const mockUsers = vi.fn(); - -vi.mock("../../../../../lib/supabase", () => ({ - createServerClient: () => ({ - rpc: mockRpc, - from: (table: string) => { - if (table === "pl_referral_codes") return { select: () => ({ in: mockUsers }) }; - return {}; - }, - }), -})); -vi.mock("../../../../../lib/airdrop/config", () => ({ - getAirdropConfig: () => ({ - CAMPAIGN_START: new Date("2026-07-01"), - CAMPAIGN_END: new Date("2026-10-01"), - MIN_REFERRAL_THRESHOLD: 50, - REFERRAL_MULTIPLIER_PER_REF: 0.2, - REFERRAL_MULTIPLIER_CAP: 3.0, - }), -})); - -import { GET } from "./route"; - -describe("GET /api/airdrop/leaderboard", () => { - it("returns entries ordered by weighted_spend DESC", async () => { - mockRpc.mockResolvedValue({ - data: [ - { address: "alice", weighted_spend: 200, buy_volume: 100, qualified_refs: 1, has_fc_bonus: 1, multiplier: 2, community_total: 500 }, - { address: "bob", weighted_spend: 300, buy_volume: 150, qualified_refs: 2, has_fc_bonus: 0, multiplier: 1.4, community_total: 500 }, - ], - error: null, - }); - mockUsers.mockResolvedValue({ data: [] }); - - const req = new NextRequest("http://localhost/api/airdrop/leaderboard"); - const res = await GET(req); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.entries[0].address).toBe("bob"); - expect(data.entries[1].address).toBe("alice"); - expect(data.entries[0].totalPoints).toBe(300); - }); - - it("returns empty when no eligible wallets", async () => { - mockRpc.mockResolvedValue({ data: [], error: null }); - mockUsers.mockResolvedValue({ data: [] }); - - const req = new NextRequest("http://localhost/api/airdrop/leaderboard"); - const res = await GET(req); - const data = await res.json(); - expect(data.entries).toHaveLength(0); - expect(data.totalParticipants).toBe(0); - }); - - it("passes correct config params to weighted_spend RPC", async () => { - mockRpc.mockResolvedValue({ data: [], error: null }); - mockUsers.mockResolvedValue({ data: [] }); - - const req = new NextRequest("http://localhost/api/airdrop/leaderboard"); - await GET(req); - expect(mockRpc).toHaveBeenCalledWith("weighted_spend", expect.objectContaining({ - p_campaign_start: expect.any(String), - p_campaign_end: expect.any(String), - p_min_referral_threshold: 50, - p_multiplier_per_ref: 0.2, - p_multiplier_cap: 3.0, - })); - }); -}); diff --git a/src/app/api/airdrop/leaderboard/route.ts b/src/app/api/airdrop/leaderboard/route.ts deleted file mode 100644 index 6359ad45..00000000 --- a/src/app/api/airdrop/leaderboard/route.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { NextResponse, type NextRequest } from "next/server"; -import { createServerClient } from "../../../../../lib/supabase"; -import { getAirdropConfig } from "../../../../../lib/airdrop/config"; - -export async function GET(req: NextRequest) { - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - const userAddress = req.nextUrl.searchParams.get("address")?.toLowerCase(); - const page = Math.max(1, parseInt(req.nextUrl.searchParams.get("page") ?? "1", 10) || 1); - const limit = Math.min(50, Math.max(1, parseInt(req.nextUrl.searchParams.get("limit") ?? "20", 10) || 20)); - - 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("[leaderboard] weighted_spend RPC failed:", error.message); - return NextResponse.json({ error: "Failed to compute leaderboard" }, { status: 500 }); - } - - const allRows = (rows ?? []) as Array<{ - address: string; - weighted_spend: number; - buy_volume: number; - qualified_refs: number; - has_fc_bonus: number; - multiplier: number; - community_total: number; - }>; - - const sorted = [...allRows].sort((a, b) => Number(b.weighted_spend) - Number(a.weighted_spend)); - - const totalParticipants = sorted.length; - const totalPages = Math.ceil(totalParticipants / limit); - const start = (page - 1) * limit; - const pageSlice = sorted.slice(start, start + limit); - - const pageAddresses = pageSlice.map(r => r.address); - const { data: users } = await supabase - .from("pl_referral_codes") - .select("address, code, is_farcaster_username") - .in("address", pageAddresses); - - const usernameMap = new Map( - (users ?? []).map((u) => [u.address.toLowerCase(), u.is_farcaster_username ? u.code : null]), - ); - - const communityTotal = Number(sorted[0]?.community_total ?? 0); - - const entries = pageSlice.map((row, i) => ({ - rank: start + i + 1, - address: row.address, - username: usernameMap.get(row.address) ?? null, - weighted_spend: Number(row.weighted_spend), - totalPoints: Number(row.weighted_spend), - buy_volume: Number(row.buy_volume), - multiplier: Number(row.multiplier), - sharePercent: communityTotal > 0 ? Math.round((Number(row.weighted_spend) / communityTotal) * 10000) / 100 : 0, - })); - - let userRank: number | null = null; - if (userAddress) { - const idx = sorted.findIndex(r => r.address === userAddress); - userRank = idx >= 0 ? idx + 1 : null; - } - - return NextResponse.json({ entries, userRank, totalParticipants, page, totalPages, limit }, { - headers: { "Cache-Control": "public, s-maxage=30, stale-while-revalidate=15" }, - }); -} diff --git a/src/app/api/airdrop/points/route.test.ts b/src/app/api/airdrop/points/route.test.ts deleted file mode 100644 index e9edb77c..00000000 --- a/src/app/api/airdrop/points/route.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -// @vitest-environment node -import { describe, expect, it, vi } from "vitest"; -import { NextRequest } from "next/server"; - -const mockPoints = vi.fn(); -const mockAllPoints = vi.fn(); -const mockStreak = vi.fn(); -const mockRefCode = vi.fn(); -const mockReferredBy = vi.fn(); -const mockRefCount = vi.fn(); - -vi.mock("../../../../../lib/supabase", () => ({ - createServerClient: () => ({ - from: (table: string) => { - if (table === "pl_points") { - return { - select: (cols: string) => { - if (cols.includes("action")) return { eq: () => mockPoints() }; - return mockAllPoints(); - }, - }; - } - if (table === "pl_streaks") return { select: () => ({ eq: () => ({ single: mockStreak }) }) }; - if (table === "pl_referral_codes") return { select: () => ({ eq: () => ({ single: mockRefCode }) }) }; - if (table === "pl_referrals") { - return { - select: (cols: string, opts?: { count?: string; head?: boolean }) => { - if (opts?.count) return { eq: mockRefCount }; - return { eq: () => ({ single: mockReferredBy }) }; - }, - }; - } - 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 }, - }, - }), -})); - -import { GET } from "./route"; - -describe("GET /api/airdrop/points", () => { - it("returns deprecated shape with buy_volume_plot + deprecation headers", async () => { - mockPoints.mockResolvedValue({ data: [{ action: "buy", points: 100 }] }); - mockAllPoints.mockResolvedValue({ data: [{ points: 100 }, { points: 200 }] }); - mockStreak.mockResolvedValue({ data: null }); - mockRefCode.mockResolvedValue({ data: { code: "mycode", is_farcaster_username: false } }); - mockReferredBy.mockResolvedValue({ data: null }); - mockRefCount.mockResolvedValue({ count: 3 }); - - const req = new NextRequest("http://localhost/api/airdrop/points?address=0xabc"); - const res = await GET(req); - expect(res.status).toBe(200); - - const data = await res.json(); - expect(data.address).toBe("0xabc"); - expect(data.buy_volume_plot).toBeDefined(); - expect(data.fetched_at).toBeDefined(); - expect(data.totalPoints).toBeDefined(); - expect(data.referral.code).toBe("mycode"); - - expect(res.headers.get("Deprecation")).toBe("true"); - expect(res.headers.get("Link")).toContain("/api/airdrop/projection"); - }); - - it("handles non-existent address gracefully", async () => { - mockPoints.mockResolvedValue({ data: [] }); - mockAllPoints.mockResolvedValue({ data: [] }); - mockStreak.mockResolvedValue({ data: null }); - mockRefCode.mockResolvedValue({ data: null }); - mockReferredBy.mockResolvedValue({ data: null }); - mockRefCount.mockResolvedValue({ count: 0 }); - - const req = new NextRequest("http://localhost/api/airdrop/points?address=0xnone"); - const res = await GET(req); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.totalPoints).toBe(0); - expect(data.buy_volume_plot).toBe(0); - }); - - it("returns 400 when address missing", async () => { - const req = new NextRequest("http://localhost/api/airdrop/points"); - const res = await GET(req); - expect(res.status).toBe(400); - }); -}); diff --git a/src/app/api/airdrop/points/route.ts b/src/app/api/airdrop/points/route.ts deleted file mode 100644 index 62bc0217..00000000 --- a/src/app/api/airdrop/points/route.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { NextResponse, type NextRequest } from "next/server"; -import { createServerClient } from "../../../../../lib/supabase"; -import { getAirdropConfig } from "../../../../../lib/airdrop/config"; - -export async function GET(req: NextRequest) { - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - const address = req.nextUrl.searchParams.get("address")?.toLowerCase(); - if (!address) { - return NextResponse.json({ error: "Missing address param" }, { status: 400 }); - } - - const config = getAirdropConfig(); - - const [pointsRes, allPointsRes, streakRes, referralCodeRes, referredByRes, referredCountRes] = await Promise.all([ - supabase.from("pl_points").select("action, points").eq("address", address), - supabase.from("pl_points").select("points"), - supabase.from("pl_streaks").select("current_streak, last_checkin, longest_streak").eq("address", address).single(), - supabase.from("pl_referral_codes").select("code, is_farcaster_username").eq("address", address).single(), - supabase.from("pl_referrals").select("referral_code").eq("referred_address", address).single(), - supabase.from("pl_referrals").select("id", { count: "exact", head: true }).eq("referrer_address", address), - ]); - - const breakdown = { buy: 0, referral: 0, write: 0, rate: 0 }; - let totalPoints = 0; - for (const row of pointsRes.data ?? []) { - const action = row.action as keyof typeof breakdown; - if (action in breakdown) { - breakdown[action] += row.points; - } - totalPoints += row.points; - } - - const globalTotal = (allPointsRes.data ?? []).reduce((sum, r) => sum + r.points, 0); - const sharePercent = globalTotal > 0 ? (totalPoints / globalTotal) * 100 : 0; - - const currentStreak = streakRes.data?.current_streak ?? 0; - const todayUtc = new Date().toISOString().slice(0, 10); - const checkedInToday = streakRes.data?.last_checkin - ? new Date(streakRes.data.last_checkin).toISOString().slice(0, 10) === todayUtc - : false; - - const estimatedAirdrop = sharePercent > 0 - ? { - bronze: Math.round((sharePercent / 100) * config.POOL_AMOUNT * (config.MILESTONES.BRONZE.pct / 100)), - silver: Math.round((sharePercent / 100) * config.POOL_AMOUNT * (config.MILESTONES.SILVER.pct / 100)), - gold: Math.round((sharePercent / 100) * config.POOL_AMOUNT * (config.MILESTONES.GOLD.pct / 100)), - diamond: Math.round((sharePercent / 100) * config.POOL_AMOUNT * (config.MILESTONES.DIAMOND.pct / 100)), - } - : { bronze: 0, silver: 0, gold: 0, diamond: 0 }; - - return NextResponse.json({ - address, - totalPoints: Math.round(totalPoints * 100) / 100, - sharePercent: Math.round(sharePercent * 100) / 100, - breakdown: { - buy: Math.round(breakdown.buy * 100) / 100, - referral: Math.round(breakdown.referral * 100) / 100, - write: Math.round(breakdown.write * 100) / 100, - rate: Math.round(breakdown.rate * 100) / 100, - }, - streak: { - currentStreak, - boostPercent: 0, - nextTier: null, - checkedInToday, - lastCheckin: streakRes.data?.last_checkin ?? null, - }, - referral: { - code: referralCodeRes.data?.code ?? null, - isFarcasterUsername: referralCodeRes.data?.is_farcaster_username ?? false, - referredBy: referredByRes.data?.referral_code ?? null, - referredUsersCount: referredCountRes.count ?? 0, - }, - estimatedAirdrop, - buy_volume_plot: breakdown.buy, - fetched_at: new Date().toISOString(), - }, { - headers: { - "Cache-Control": "public, s-maxage=10, stale-while-revalidate=5", - "Deprecation": "true", - "Link": "; rel=\"successor-version\"", - }, - }); -} diff --git a/src/app/api/airdrop/projection/route.test.ts b/src/app/api/airdrop/projection/route.test.ts deleted file mode 100644 index f631a92b..00000000 --- a/src/app/api/airdrop/projection/route.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -// @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 deleted file mode 100644 index 1ea12f0a..00000000 --- a/src/app/api/airdrop/projection/route.ts +++ /dev/null @@ -1,93 +0,0 @@ -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/src/app/api/airdrop/proof/route.ts b/src/app/api/airdrop/proof/route.ts deleted file mode 100644 index 4dafd813..00000000 --- a/src/app/api/airdrop/proof/route.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Merkle proof for airdrop claim (#894) - * GET /api/airdrop/proof?address=0x... - */ - -import { NextResponse, type NextRequest } from "next/server"; -import { createServerClient } from "../../../../../lib/supabase"; - -export async function GET(req: NextRequest) { - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - const address = req.nextUrl.searchParams.get("address")?.toLowerCase(); - if (!address) { - return NextResponse.json({ error: "Missing address param" }, { status: 400 }); - } - - const { data, error } = await supabase - .from("pl_airdrop_proofs") - .select("amount, proof, merkle_root") - .eq("address", address) - .single(); - - if (error || !data) { - return NextResponse.json({ eligible: false, amount: null, proof: null, claimed: false }); - } - - return NextResponse.json({ - eligible: true, - amount: data.amount, - proof: JSON.parse(data.proof), - merkleRoot: data.merkle_root, - claimed: false, // On-chain claim status checked client-side via contract read - }); -} diff --git a/src/app/api/airdrop/referral-code/route.test.ts b/src/app/api/airdrop/referral-code/route.test.ts deleted file mode 100644 index 66266e38..00000000 --- a/src/app/api/airdrop/referral-code/route.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -// @vitest-environment node -import { describe, expect, it, vi, beforeEach } from "vitest"; - -const mockInsert = vi.fn().mockReturnValue({ error: null }); -const mockSelectCode = vi.fn(); - -vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({ - verifySiweRequest: vi.fn(), -})); -vi.mock("../../../../../lib/rate-limit", () => ({ - checkRateLimit: () => Promise.resolve(true), - getClientIp: () => "127.0.0.1", -})); -vi.mock("../../../../../lib/supabase", () => ({ - createServerClient: () => ({ - from: (table: string) => { - if (table === "pl_referral_codes") { - return { - select: () => ({ eq: () => ({ single: mockSelectCode }) }), - insert: mockInsert, - }; - } - return {}; - }, - }), -})); -vi.mock("nanoid", () => ({ nanoid: () => "testcode" })); - -import { GET, POST } from "./route"; -import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; -import { NextRequest } from "next/server"; - -function makePostReq(body: unknown) { - return new Request("http://localhost/api/airdrop/referral-code", { - method: "POST", - body: JSON.stringify(body), - headers: { "content-type": "application/json" }, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - mockInsert.mockReturnValue({ error: null }); -}); - -describe("POST /api/airdrop/referral-code", () => { - it("returns 401 on expired SIWE signature", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "expired" }); - const res = await POST(makePostReq({ message: "m", signature: "s" })); - expect(res.status).toBe(401); - expect(mockInsert).not.toHaveBeenCalled(); - }); - - it("returns 401 on wrong chainId", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "chain_id_mismatch" }); - const res = await POST(makePostReq({ message: "m", signature: "s" })); - expect(res.status).toBe(401); - expect(mockInsert).not.toHaveBeenCalled(); - }); - - it("succeeds with valid SIWE and generates code", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); - mockSelectCode.mockResolvedValue({ data: null }); - - const res = await POST(makePostReq({ message: "m", signature: "s" })); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.code).toBe("testcode"); - }); -}); - -describe("GET /api/airdrop/referral-code", () => { - it("remains unauthenticated — returns code without auth headers", async () => { - mockSelectCode.mockResolvedValue({ data: { code: "mycode", is_farcaster_username: false } }); - const req = new NextRequest("http://localhost/api/airdrop/referral-code?address=0xabc"); - - const res = await GET(req); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.code).toBe("mycode"); - expect(data).toHaveProperty("is_farcaster_username"); - }); - - it("returns { code: null } for unknown address", async () => { - mockSelectCode.mockResolvedValue({ data: null }); - const req = new NextRequest("http://localhost/api/airdrop/referral-code?address=0xnone"); - - const res = await GET(req); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.code).toBeNull(); - }); -}); diff --git a/src/app/api/airdrop/referral-code/route.ts b/src/app/api/airdrop/referral-code/route.ts deleted file mode 100644 index da1f580f..00000000 --- a/src/app/api/airdrop/referral-code/route.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Referral code endpoint (#883) - * - * GET /api/airdrop/referral-code?address=0x... — fetch existing code (no creation) - * POST /api/airdrop/referral-code — generate or retrieve code - * Body: { message: string, signature: string, useFarcasterUsername?: boolean } - */ - -import { NextResponse, type NextRequest } from "next/server"; -import { nanoid } from "nanoid"; -import { createServerClient } from "../../../../../lib/supabase"; -import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; -import { checkRateLimit, getClientIp } from "../../../../../lib/rate-limit"; - -export async function GET(req: NextRequest) { - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - const address = req.nextUrl.searchParams.get("address")?.toLowerCase(); - if (!address) { - return NextResponse.json({ error: "Missing address param" }, { status: 400 }); - } - - const { data } = await supabase - .from("pl_referral_codes") - .select("code, is_farcaster_username") - .eq("address", address) - .single(); - - if (!data) { - return NextResponse.json({ code: null }); - } - - return NextResponse.json({ code: data.code, is_farcaster_username: data.is_farcaster_username }); -} - -export async function POST(req: Request) { - const ip = getClientIp(req); - if (!(await checkRateLimit(ip, "airdrop/referral-code"))) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - let message: string; - let signature: string; - let useFarcasterUsername: boolean; - try { - const body = await req.json(); - message = body.message; - signature = body.signature; - useFarcasterUsername = body.useFarcasterUsername === true; - if (!message || !signature) throw new Error("missing fields"); - } catch { - return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); - } - - const auth = await verifySiweRequest(message, signature); - if (!auth.ok) { - return NextResponse.json({ error: auth.error }, { status: 401 }); - } - const address = auth.address; - - // Check for existing code (immutable once set) - const { data: existing } = await supabase - .from("pl_referral_codes") - .select("code, is_farcaster_username") - .eq("address", address) - .single(); - - if (existing) { - return NextResponse.json({ code: existing.code, is_farcaster_username: existing.is_farcaster_username }); - } - - let code: string; - let isFarcasterUsername = false; - - if (useFarcasterUsername) { - // Look up Farcaster username via users table - const { data: user } = await supabase - .from("users") - .select("username, fid") - .or(`primary_address.ilike.${address},custody_address.ilike.${address}`) - .not("fid", "is", null) - .single(); - - if (!user?.username) { - return NextResponse.json({ error: "No Farcaster account found for this wallet" }, { status: 400 }); - } - - // Check if username is already taken as a code by another wallet - const { data: taken } = await supabase - .from("pl_referral_codes") - .select("address") - .eq("code", user.username) - .single(); - - if (taken) { - return NextResponse.json({ error: "Farcaster username already in use as referral code" }, { status: 409 }); - } - - code = user.username; - isFarcasterUsername = true; - } else { - code = nanoid(8); - } - - const { error } = await supabase.from("pl_referral_codes").insert({ - address, - code, - is_farcaster_username: isFarcasterUsername, - }); - - if (error) { - // Handle race condition — another request may have inserted first - if (error.code === "23505") { - const { data: retry } = await supabase - .from("pl_referral_codes") - .select("code, is_farcaster_username") - .eq("address", address) - .single(); - if (retry) { - return NextResponse.json({ code: retry.code, is_farcaster_username: retry.is_farcaster_username }); - } - } - console.error("[referral-code] Insert failed:", error.message); - return NextResponse.json({ error: "Failed to generate code" }, { status: 500 }); - } - - return NextResponse.json({ code, is_farcaster_username: isFarcasterUsername }); -} diff --git a/src/app/api/airdrop/register-referral/route.test.ts b/src/app/api/airdrop/register-referral/route.test.ts deleted file mode 100644 index 3351c217..00000000 --- a/src/app/api/airdrop/register-referral/route.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -// @vitest-environment node -import { describe, expect, it, vi, beforeEach } from "vitest"; - -const mockInsert = vi.fn().mockReturnValue({ error: null }); -const mockSelectReferred = vi.fn(); -const mockSelectCode = vi.fn(); - -vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({ - verifySiweRequest: vi.fn(), -})); -vi.mock("../../../../../lib/rate-limit", () => ({ - checkRateLimit: () => Promise.resolve(true), - getClientIp: () => "127.0.0.1", -})); -vi.mock("../../../../../lib/supabase", () => ({ - createServerClient: () => ({ - from: (table: string) => { - if (table === "pl_referrals") { - return { - select: () => ({ eq: () => ({ single: mockSelectReferred }) }), - insert: mockInsert, - }; - } - if (table === "pl_referral_codes") { - return { select: () => ({ eq: () => ({ single: mockSelectCode }) }) }; - } - return {}; - }, - }), -})); - -import { POST } from "./route"; -import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; - -function makeReq(body: unknown) { - return new Request("http://localhost/api/airdrop/register-referral", { - method: "POST", - body: JSON.stringify(body), - headers: { "content-type": "application/json" }, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - mockInsert.mockReturnValue({ error: null }); -}); - -describe("POST /api/airdrop/register-referral", () => { - it("returns 401 on expired SIWE signature", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "expired" }); - const res = await POST(makeReq({ message: "m", signature: "s", referralCode: "abc" })); - expect(res.status).toBe(401); - expect(mockInsert).not.toHaveBeenCalled(); - }); - - it("returns 401 on wrong domain", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "domain_mismatch" }); - const res = await POST(makeReq({ message: "m", signature: "s", referralCode: "abc" })); - expect(res.status).toBe(401); - expect(mockInsert).not.toHaveBeenCalled(); - }); - - it("succeeds with valid SIWE signature", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); - mockSelectReferred.mockResolvedValue({ data: null }); - mockSelectCode.mockResolvedValue({ data: { address: "0xref" } }); - - const res = await POST(makeReq({ message: "m", signature: "s", referralCode: "code1" })); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - }); -}); diff --git a/src/app/api/airdrop/register-referral/route.ts b/src/app/api/airdrop/register-referral/route.ts deleted file mode 100644 index 5c537808..00000000 --- a/src/app/api/airdrop/register-referral/route.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Referral registration endpoint (#883) - * - * GET /api/airdrop/register-referral?address=0x... — check existing referrer - * POST /api/airdrop/register-referral — register referral (SIWE) - * Body: { message: string, signature: string, referralCode: string } - * - * Records a referral relationship. One referrer per wallet, first-come. - * No retroactive points — only applies to future buy-points. - */ - -import { NextResponse, type NextRequest } from "next/server"; -import { createServerClient } from "../../../../../lib/supabase"; -import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; -import { checkRateLimit, getClientIp } from "../../../../../lib/rate-limit"; - -export async function GET(req: NextRequest) { - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - const address = req.nextUrl.searchParams.get("address")?.toLowerCase(); - if (!address) { - return NextResponse.json({ error: "Missing address param" }, { status: 400 }); - } - - const { data } = await supabase - .from("pl_referrals") - .select("referrer_address, referral_code") - .eq("referred_address", address) - .single(); - - if (!data) { - return NextResponse.json({ referrer: null }); - } - - // Look up referrer's display name from referral code table - const { data: codeData } = await supabase - .from("pl_referral_codes") - .select("code, is_farcaster_username") - .eq("address", data.referrer_address) - .single(); - - const displayName = codeData?.is_farcaster_username - ? `@${codeData.code}` - : data.referral_code; - - return NextResponse.json({ - referrer: data.referrer_address, - displayName, - }); -} - -export async function POST(req: Request) { - const ip = getClientIp(req); - if (!(await checkRateLimit(ip, "airdrop/register-referral"))) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - let message: string; - let signature: string; - let referralCode: string; - try { - const body = await req.json(); - message = body.message; - signature = body.signature; - referralCode = body.referralCode?.trim(); - if (!message || !signature || !referralCode) throw new Error("missing fields"); - } catch { - return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); - } - - const auth = await verifySiweRequest(message, signature); - if (!auth.ok) { - return NextResponse.json({ error: auth.error }, { status: 401 }); - } - const address = auth.address; - - // Check if already referred - const { data: existing } = await supabase - .from("pl_referrals") - .select("referrer_address") - .eq("referred_address", address) - .single(); - - if (existing) { - return NextResponse.json({ error: "Already referred", referrer: existing.referrer_address }, { status: 409 }); - } - - // Look up referrer by code - const { data: referrer } = await supabase - .from("pl_referral_codes") - .select("address") - .eq("code", referralCode) - .single(); - - if (!referrer) { - return NextResponse.json({ error: "Invalid referral code" }, { status: 404 }); - } - - // Prevent self-referral - if (referrer.address.toLowerCase() === address) { - return NextResponse.json({ error: "Cannot refer yourself" }, { status: 400 }); - } - - const { error } = await supabase.from("pl_referrals").insert({ - referrer_address: referrer.address.toLowerCase(), - referred_address: address, - referral_code: referralCode, - }); - - if (error) { - if (error.code === "23505") { - return NextResponse.json({ error: "Already referred" }, { status: 409 }); - } - console.error("[register-referral] Insert failed:", error.message); - return NextResponse.json({ error: "Registration failed" }, { status: 500 }); - } - - return NextResponse.json({ success: true, referrer: referrer.address.toLowerCase() }); -} diff --git a/src/app/api/airdrop/results/route.ts b/src/app/api/airdrop/results/route.ts deleted file mode 100644 index c93a4114..00000000 --- a/src/app/api/airdrop/results/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Finalized campaign results (#894) - * GET /api/airdrop/results - * - * Derives final distribution from pl_airdrop_proofs (written by finalize script). - */ - -import { NextResponse } from "next/server"; -import { createServerClient } from "../../../../../lib/supabase"; -import { AIRDROP_CONFIG } from "../../../../../lib/airdrop/config"; -import { formatUnits } from "viem"; - -export async function GET() { - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - // Read all finalized proof amounts - const { data, error } = await supabase - .from("pl_airdrop_proofs") - .select("amount"); - - if (error || !data || data.length === 0) { - return NextResponse.json({ finalized: false }); - } - - // Sum all distributed amounts (stored as wei strings) - let totalDistributedWei = BigInt(0); - for (const row of data) { - totalDistributedWei += BigInt(row.amount); - } - const distributedPlot = Number(formatUnits(totalDistributedWei, 18)); - - const poolAmount = AIRDROP_CONFIG.POOL_AMOUNT; - const burnedPlot = poolAmount - distributedPlot; - - // Determine milestone from distributed percentage - const distributedPct = (distributedPlot / poolAmount) * 100; - let milestone: string; - if (distributedPct >= AIRDROP_CONFIG.MILESTONES.DIAMOND.pct - 0.1) { - milestone = "\uD83D\uDC8E Diamond"; - } else if (distributedPct >= AIRDROP_CONFIG.MILESTONES.GOLD.pct - 0.1) { - milestone = "\uD83E\uDD47 Gold"; - } else if (distributedPct >= AIRDROP_CONFIG.MILESTONES.SILVER.pct - 0.1) { - milestone = "\uD83E\uDD48 Silver"; - } else if (distributedPct >= AIRDROP_CONFIG.MILESTONES.BRONZE.pct - 0.1) { - milestone = "\uD83E\uDD49 Bronze"; - } else { - milestone = "None"; - } - - return NextResponse.json({ - finalized: true, - milestone, - distributedPct: Math.round(distributedPct), - distributedPlot: Math.round(distributedPlot), - burnedPlot: Math.round(burnedPlot), - recipients: data.length, - }); -} diff --git a/src/app/api/airdrop/snapshots/route.ts b/src/app/api/airdrop/snapshots/route.ts deleted file mode 100644 index cc26f5d1..00000000 --- a/src/app/api/airdrop/snapshots/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Weekly snapshot history (#885) - * GET /api/airdrop/snapshots - */ - -import { NextResponse } from "next/server"; -import { createServerClient } from "../../../../../lib/supabase"; - -export async function GET() { - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - const { data: snapshots, error } = await supabase - .from("pl_weekly_snapshots") - .select("week_number, week_start, new_stories, token_buys, new_referrals, mcap_start, mcap_end, total_pl_earned") - .order("week_number", { ascending: false }); - - if (error) { - console.error("[airdrop/snapshots] Query failed:", error.message); - return NextResponse.json({ error: "Failed to fetch snapshots" }, { status: 500 }); - } - - return NextResponse.json({ - snapshots: (snapshots ?? []).map((s) => ({ - weekNumber: s.week_number, - weekStart: s.week_start, - newStories: s.new_stories, - tokenBuys: s.token_buys, - newReferrals: s.new_referrals, - mcapStart: s.mcap_start, - mcapEnd: s.mcap_end, - totalPlEarned: s.total_pl_earned, - })), - }, { - headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=60" }, - }); -} diff --git a/src/app/api/airdrop/status/route.test.ts b/src/app/api/airdrop/status/route.test.ts deleted file mode 100644 index c1e68d50..00000000 --- a/src/app/api/airdrop/status/route.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -// @vitest-environment node -import { describe, expect, it, vi } from "vitest"; - -const mockPriceSingle = vi.fn(); -vi.mock("../../../../../lib/supabase", () => ({ - createServerClient: () => ({ - from: (table: string) => { - if (table === "pl_daily_prices") { - return { select: () => ({ order: () => ({ limit: () => ({ single: mockPriceSingle }) }) }) }; - } - if (table === "pl_activations") { - const allCount = 15; - const eligibleCount = 12; - return { - select: () => ({ - not: () => { - const base = Promise.resolve({ count: allCount }); - return Object.assign(base, { - eq: () => Promise.resolve({ count: eligibleCount }), - }); - }, - }), - }; - } - 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 }, - }, - LOCKER_TX: null, - SIWE_DOMAIN: "plotlink.xyz", - SIWE_URI: "https://plotlink.xyz/airdrop", - SIWE_STATEMENT: "PlotLink Buy-Back Sprint activation", - SIWE_CHAIN_ID: 8453, - PLOTLINK_X_HANDLE: "plotlinkxyz", - PLOTLINK_FC_FID: 0, - }), -})); -vi.mock("../../../../../lib/usd-price", () => ({ getPlotUsdPrice: () => Promise.resolve(0.037) })); - -import { GET } from "./route"; - - -describe("GET /api/airdrop/status", () => { - it("returns v5 shape with milestones + activation counts + env_check", async () => { - mockPriceSingle.mockResolvedValue({ data: { price_usd: 0.037, mcap_usd: 37000 } }); - - const res = await GET(); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.milestones.bronze.mcap).toBe(100_000); - expect(data.milestones.diamond.mcap).toBe(10_000_000); - expect(data.poolAmount).toBe(200_000); - expect(data.activation_count).toBe(15); - expect(data.eligible_activation_count).toBe(12); - expect(typeof data.env_check.all_present).toBe("boolean"); - }); - - it("env_check.all_present is false when required env vars missing", async () => { - mockPriceSingle.mockResolvedValue({ data: null }); - - const res = await GET(); - const data = await res.json(); - expect(data.env_check.all_present).toBe(false); - }); -}); diff --git a/src/app/api/airdrop/status/route.ts b/src/app/api/airdrop/status/route.ts deleted file mode 100644 index 8560fbfd..00000000 --- a/src/app/api/airdrop/status/route.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { NextResponse } from "next/server"; -import { createServerClient } from "../../../../../lib/supabase"; -import { getAirdropConfig } from "../../../../../lib/airdrop/config"; -import { getPlotUsdPrice } from "../../../../../lib/usd-price"; - -function checkEnvConfig(): boolean { - const secrets = [ - process.env.TWITTERAPI_IO_KEY, - process.env.NEYNAR_API_KEY, - process.env.SUPABASE_SERVICE_ROLE_KEY, - process.env.CRON_SECRET, - ]; - if (secrets.some(s => !s)) return false; - - const config = getAirdropConfig(); - if (!config.SIWE_DOMAIN || !config.SIWE_URI || !config.SIWE_STATEMENT) return false; - if (!config.SIWE_CHAIN_ID || !config.PLOTLINK_X_HANDLE) return false; - if (!config.PLOTLINK_FC_FID) return false; - if (config.POOL_AMOUNT <= 0) return false; - if (config.CAMPAIGN_START >= config.CAMPAIGN_END) return false; - if (!config.MILESTONES.BRONZE || !config.MILESTONES.SILVER || !config.MILESTONES.GOLD || !config.MILESTONES.DIAMOND) return false; - - return true; -} - -export async function GET() { - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - const config = getAirdropConfig(); - const now = new Date(); - const start = config.CAMPAIGN_START; - const end = config.CAMPAIGN_END; - const totalMs = end.getTime() - start.getTime(); - const elapsedMs = Math.max(0, now.getTime() - start.getTime()); - const remainingMs = Math.max(0, end.getTime() - now.getTime()); - - const [priceRes, activationRes, eligibleRes] = await Promise.all([ - supabase - .from("pl_daily_prices") - .select("price_usd, mcap_usd") - .order("recorded_at", { ascending: false }) - .limit(1) - .single(), - supabase - .from("pl_activations") - .select("address", { count: "exact", head: true }) - .not("activated_at", "is", null), - supabase - .from("pl_activations") - .select("address", { count: "exact", head: true }) - .not("activated_at", "is", null) - .eq("is_blacklisted", false), - ]); - - const latestPrice = priceRes.data; - const livePriceUsd = latestPrice?.price_usd ?? (await getPlotUsdPrice()); - - const MAX_SUPPLY = 1_000_000; - const currentFdv = latestPrice?.mcap_usd - ? Number(latestPrice.mcap_usd) - : livePriceUsd - ? livePriceUsd * MAX_SUPPLY - : 0; - - const milestones = { - bronze: { - mcap: config.MILESTONES.BRONZE.mcap, - pct: config.MILESTONES.BRONZE.pct, - reached: currentFdv >= config.MILESTONES.BRONZE.mcap, - }, - silver: { - mcap: config.MILESTONES.SILVER.mcap, - pct: config.MILESTONES.SILVER.pct, - reached: currentFdv >= config.MILESTONES.SILVER.mcap, - }, - gold: { - mcap: config.MILESTONES.GOLD.mcap, - pct: config.MILESTONES.GOLD.pct, - reached: currentFdv >= config.MILESTONES.GOLD.mcap, - }, - diamond: { - mcap: config.MILESTONES.DIAMOND.mcap, - pct: config.MILESTONES.DIAMOND.pct, - reached: currentFdv >= config.MILESTONES.DIAMOND.mcap, - }, - }; - - return NextResponse.json({ - campaignStart: start.toISOString().slice(0, 10), - campaignEnd: end.toISOString().slice(0, 10), - timeRemainingDays: Math.ceil(remainingMs / (1000 * 60 * 60 * 24)), - timeElapsedPercent: totalMs > 0 ? Math.min(100, Math.round((elapsedMs / totalMs) * 100)) : 0, - poolAmount: config.POOL_AMOUNT, - currentFdv, - latestPriceUsd: livePriceUsd ?? null, - milestones, - activation_count: activationRes.count ?? 0, - eligible_activation_count: eligibleRes.count ?? 0, - env_check: { all_present: checkEnvConfig() }, - lockerTx: config.LOCKER_TX, - }, { - headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=30" }, - }); -} diff --git a/src/app/api/airdrop/verify-fc/route.test.ts b/src/app/api/airdrop/verify-fc/route.test.ts deleted file mode 100644 index a10ca1ec..00000000 --- a/src/app/api/airdrop/verify-fc/route.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -// @vitest-environment node -import { describe, expect, it, vi, beforeEach } from "vitest"; - -const mockUpdate = vi.fn(); -let updateResult: { data: unknown; error: unknown } = { data: { address: "0xabc" }, error: null }; - -vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({ - verifySiweRequest: vi.fn(), -})); -vi.mock("../../../../../lib/airdrop/activation-verify", () => ({ - verifyFc: vi.fn(), -})); -vi.mock("../../../../../lib/airdrop/config", () => ({ - getAirdropConfig: () => ({ PLOTLINK_FC_FID: 12345 }), -})); -vi.mock("../../../../../lib/supabase", () => ({ - createServerClient: () => ({ - from: () => ({ - update: (data: unknown) => { - mockUpdate(data); - return { - eq: () => ({ - select: () => ({ - single: () => Promise.resolve(updateResult), - }), - }), - }; - }, - }), - }), -})); - -import { POST } from "./route"; -import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; -import { verifyFc } from "../../../../../lib/airdrop/activation-verify"; - -function makeReq(body: unknown) { - return new Request("http://localhost/api/airdrop/verify-fc", { - method: "POST", - body: JSON.stringify(body), - headers: { "content-type": "application/json" }, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - updateResult = { data: { address: "0xabc" }, error: null }; -}); - -describe("POST /api/airdrop/verify-fc", () => { - it("returns 401 on invalid SIWE signature", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "expired" }); - const res = await POST(makeReq({ message: "m", signature: "s", username: "u" })); - expect(res.status).toBe(401); - expect(mockUpdate).not.toHaveBeenCalled(); - }); - - it("returns 404 for user_not_found — no DB write", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); - vi.mocked(verifyFc).mockResolvedValue({ ok: false, error: "user_not_found" }); - const res = await POST(makeReq({ message: "m", signature: "s", username: "ghost" })); - expect(res.status).toBe(404); - expect(mockUpdate).not.toHaveBeenCalled(); - }); - - it("returns 422 for not_following — no DB write", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); - vi.mocked(verifyFc).mockResolvedValue({ ok: false, error: "not_following" }); - const res = await POST(makeReq({ message: "m", signature: "s", username: "nofol" })); - expect(res.status).toBe(422); - expect(mockUpdate).not.toHaveBeenCalled(); - }); - - it("returns 502 for neynar_error — no DB write", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); - vi.mocked(verifyFc).mockResolvedValue({ ok: false, error: "neynar_error" }); - const res = await POST(makeReq({ message: "m", signature: "s", username: "err" })); - expect(res.status).toBe(502); - expect(mockUpdate).not.toHaveBeenCalled(); - }); - - it("persists fid + fc_handle + fc_verified_at on success", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); - vi.mocked(verifyFc).mockResolvedValue({ ok: true, fid: 999 }); - const res = await POST(makeReq({ message: "m", signature: "s", username: "TestUser" })); - expect(res.status).toBe(200); - expect(mockUpdate).toHaveBeenCalledWith( - expect.objectContaining({ fid: 999, fc_handle: "testuser", fc_verified_at: expect.any(String) }), - ); - }); - - it("returns 409 on FID UNIQUE conflict", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); - vi.mocked(verifyFc).mockResolvedValue({ ok: true, fid: 999 }); - updateResult = { data: null, error: { code: "23505", message: "unique violation" } }; - const res = await POST(makeReq({ message: "m", signature: "s", username: "dupe" })); - expect(res.status).toBe(409); - }); - - it("returns 400 when no activation row exists", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xnew" }); - vi.mocked(verifyFc).mockResolvedValue({ ok: true, fid: 888 }); - updateResult = { data: null, error: { code: "PGRST116", message: "no rows" } }; - const res = await POST(makeReq({ message: "m", signature: "s", username: "newuser" })); - expect(res.status).toBe(400); - const data = await res.json(); - expect(data.error).toContain("Must confirm X handle first"); - }); -}); diff --git a/src/app/api/airdrop/verify-fc/route.ts b/src/app/api/airdrop/verify-fc/route.ts deleted file mode 100644 index 91852186..00000000 --- a/src/app/api/airdrop/verify-fc/route.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { NextResponse } from "next/server"; -import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; -import { verifyFc } from "../../../../../lib/airdrop/activation-verify"; -import { getAirdropConfig } from "../../../../../lib/airdrop/config"; -import { createServerClient } from "../../../../../lib/supabase"; - -export async function POST(req: Request) { - let message: string, signature: string, username: string; - try { - const body = await req.json(); - message = body.message; - signature = body.signature; - username = body.username; - if (!message || !signature || !username) throw new Error(); - } catch { - return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); - } - - const auth = await verifySiweRequest(message, signature); - if (!auth.ok) { - return NextResponse.json({ error: auth.error }, { status: 401 }); - } - - const config = getAirdropConfig(); - const result = await verifyFc(username, config.PLOTLINK_FC_FID); - - if (!result.ok) { - const statusMap = { - user_not_found: 404, - not_following: 422, - neynar_error: 502, - } as const; - return NextResponse.json({ error: result.error }, { status: statusMap[result.error] }); - } - - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - const address = auth.address; - const now = new Date().toISOString(); - - const { data: updated, error } = await supabase - .from("pl_activations") - .update({ - fid: result.fid, - fc_handle: username.toLowerCase(), - fc_verified_at: now, - }) - .eq("address", address) - .select("address") - .single(); - - if (error) { - if (error.code === "23505") { - return NextResponse.json({ error: "Farcaster account already linked to another wallet" }, { status: 409 }); - } - if (error.code === "PGRST116") { - return NextResponse.json({ error: "Must confirm X handle first" }, { status: 400 }); - } - console.error("[verify-fc] Update failed:", error.message); - return NextResponse.json({ error: "Failed to save FC verification" }, { status: 500 }); - } - - if (!updated) { - return NextResponse.json({ error: "Must confirm X handle first" }, { status: 400 }); - } - - return NextResponse.json({ - address, - fid: result.fid, - fc_handle: username.toLowerCase(), - fc_verified_at: now, - }); -} diff --git a/src/app/api/airdrop/verify-x-handle/route.test.ts b/src/app/api/airdrop/verify-x-handle/route.test.ts deleted file mode 100644 index 8ac895c0..00000000 --- a/src/app/api/airdrop/verify-x-handle/route.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -// @vitest-environment node -import { describe, expect, it, vi, beforeEach } from "vitest"; - -vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({ - verifySiweRequest: vi.fn(), -})); -vi.mock("../../../../../lib/airdrop/twitterapi", () => ({ - lookupXUser: vi.fn(), -})); - -import { POST } from "./route"; -import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; -import { lookupXUser } from "../../../../../lib/airdrop/twitterapi"; - -function makeReq(body: unknown) { - return new Request("http://localhost/api/airdrop/verify-x-handle", { - method: "POST", - body: JSON.stringify(body), - headers: { "content-type": "application/json" }, - }); -} - -beforeEach(() => { vi.clearAllMocks(); }); - -describe("POST /api/airdrop/verify-x-handle", () => { - it("returns 401 on invalid signature", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "expired" }); - const res = await POST(makeReq({ message: "m", signature: "s", username: "u" })); - expect(res.status).toBe(401); - }); - - it("returns lookup data without persisting", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); - vi.mocked(lookupXUser).mockResolvedValue({ - display_name: "Test", avatar_url: "url", follower_count: 100, x_user_id: "123", bio_snippet: "bio", - }); - const res = await POST(makeReq({ message: "m", signature: "s", username: "testuser" })); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.display_name).toBe("Test"); - expect(data.x_user_id).toBe("123"); - }); - - it("returns 404 when user not found", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); - vi.mocked(lookupXUser).mockResolvedValue(null); - const res = await POST(makeReq({ message: "m", signature: "s", username: "ghost" })); - expect(res.status).toBe(404); - }); -}); diff --git a/src/app/api/airdrop/verify-x-handle/route.ts b/src/app/api/airdrop/verify-x-handle/route.ts deleted file mode 100644 index 539442a0..00000000 --- a/src/app/api/airdrop/verify-x-handle/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NextResponse } from "next/server"; -import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; -import { lookupXUser } from "../../../../../lib/airdrop/twitterapi"; - -export async function POST(req: Request) { - let message: string, signature: string, username: string; - try { - const body = await req.json(); - message = body.message; - signature = body.signature; - username = body.username; - if (!message || !signature || !username) throw new Error(); - } catch { - return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); - } - - const auth = await verifySiweRequest(message, signature); - if (!auth.ok) { - return NextResponse.json({ error: auth.error }, { status: 401 }); - } - - const user = await lookupXUser(username); - if (!user) { - return NextResponse.json({ error: "X user not found" }, { status: 404 }); - } - - return NextResponse.json(user); -} diff --git a/src/app/api/airdrop/x-follow-click/route.test.ts b/src/app/api/airdrop/x-follow-click/route.test.ts deleted file mode 100644 index 4c12ea63..00000000 --- a/src/app/api/airdrop/x-follow-click/route.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -// @vitest-environment node -import { describe, expect, it, vi, beforeEach } from "vitest"; - -const mockUpdate = vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ error: null }) }); -const mockSelectSingle = vi.fn(); - -vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({ - verifySiweRequest: vi.fn(), -})); -vi.mock("../../../../../lib/supabase", () => ({ - createServerClient: () => ({ - from: () => ({ - select: () => ({ eq: () => ({ single: mockSelectSingle }) }), - update: (data: unknown) => { - mockUpdate(data); - return { eq: vi.fn().mockReturnValue({ error: null }) }; - }, - }), - }), -})); - -import { POST } from "./route"; -import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; - -function makeReq(body: unknown) { - return new Request("http://localhost/api/airdrop/x-follow-click", { - method: "POST", - body: JSON.stringify(body), - headers: { "content-type": "application/json" }, - }); -} - -beforeEach(() => { vi.clearAllMocks(); }); - -describe("POST /api/airdrop/x-follow-click", () => { - it("returns 401 on invalid signature", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "expired" }); - const res = await POST(makeReq({ message: "m", signature: "s" })); - expect(res.status).toBe(401); - }); - - it("returns 400 if no activation row exists", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); - mockSelectSingle.mockResolvedValue({ data: null }); - const res = await POST(makeReq({ message: "m", signature: "s" })); - expect(res.status).toBe(400); - }); - - it("sets activated_at when handle confirmed + follow click completes activation", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); - mockSelectSingle.mockResolvedValue({ - data: { x_handle_confirmed_at: "2026-07-01", x_follow_at: null, activated_at: null }, - }); - - const res = await POST(makeReq({ message: "m", signature: "s" })); - expect(res.status).toBe(200); - expect(mockUpdate).toHaveBeenCalledWith( - expect.objectContaining({ activated_at: expect.any(String) }), - ); - const data = await res.json(); - expect(data.activated).toBe(true); - }); - - it("does not re-set activated_at if already activated", async () => { - vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); - mockSelectSingle.mockResolvedValue({ - data: { x_handle_confirmed_at: "2026-07-01", x_follow_at: "2026-07-01", activated_at: "2026-07-01" }, - }); - - const res = await POST(makeReq({ message: "m", signature: "s" })); - expect(res.status).toBe(200); - expect(mockUpdate).toHaveBeenCalledWith( - expect.not.objectContaining({ activated_at: expect.anything() }), - ); - }); -}); diff --git a/src/app/api/airdrop/x-follow-click/route.ts b/src/app/api/airdrop/x-follow-click/route.ts deleted file mode 100644 index 3edf20ba..00000000 --- a/src/app/api/airdrop/x-follow-click/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { NextResponse } from "next/server"; -import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; -import { createServerClient } from "../../../../../lib/supabase"; - -export async function POST(req: Request) { - let message: string, signature: string; - try { - const body = await req.json(); - message = body.message; - signature = body.signature; - if (!message || !signature) throw new Error(); - } catch { - return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); - } - - const auth = await verifySiweRequest(message, signature); - if (!auth.ok) { - return NextResponse.json({ error: auth.error }, { status: 401 }); - } - - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - const address = auth.address; - const now = new Date().toISOString(); - - const { data: existing } = await supabase - .from("pl_activations") - .select("x_handle_confirmed_at, x_follow_at, activated_at") - .eq("address", address) - .single(); - - if (!existing) { - return NextResponse.json({ error: "Must confirm X handle first" }, { status: 400 }); - } - - const handleConfirmed = existing.x_handle_confirmed_at !== null; - const alreadyActivated = existing.activated_at !== null; - - const { error } = await supabase - .from("pl_activations") - .update({ - x_follow_at: now, - ...(handleConfirmed && !alreadyActivated ? { activated_at: now } : {}), - }) - .eq("address", address); - - if (error) { - console.error("[x-follow-click] Update failed:", error.message); - return NextResponse.json({ error: "Failed to record follow" }, { status: 500 }); - } - - return NextResponse.json({ - address, - x_follow_at: now, - activated: handleConfirmed && !alreadyActivated ? true : alreadyActivated, - }); -} diff --git a/src/app/api/cron/airdrop-points/route.ts b/src/app/api/cron/airdrop-points/route.ts deleted file mode 100644 index bbe411f3..00000000 --- a/src/app/api/cron/airdrop-points/route.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { NextResponse } from "next/server"; -import { createServerClient } from "../../../../../lib/supabase"; -import { ZAP_PLOTLINK } from "../../../../../lib/contracts/constants"; -import { getAirdropConfig } from "../../../../../lib/airdrop/config"; -import { computeBuyPoints } from "../../../../../lib/airdrop/points"; - -function verifyCron(req: Request): boolean { - const secret = process.env.CRON_SECRET; - if (!secret) { - return process.env.NODE_ENV !== "production"; - } - const authHeader = req.headers.get("authorization"); - return authHeader === `Bearer ${secret}`; -} - -async function handler(req: Request) { - if (!verifyCron(req)) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - if (process.env.NEXT_PUBLIC_AIRDROP_PAUSED === "1") { - return NextResponse.json({ paused: true }); - } - - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - const config = getAirdropConfig(new Date()); - const now = new Date(); - if (now > config.CAMPAIGN_END) { - return NextResponse.json({ message: "Campaign ended, no points awarded" }); - } - - const zapAddress = ZAP_PLOTLINK.toLowerCase(); - - const { data: trades, error: tradesErr } = await supabase - .from("trade_history") - .select("id, user_address, reserve_amount, block_timestamp") - .eq("event_type", "mint") - .gte("block_timestamp", config.CAMPAIGN_START.toISOString()) - .lte("block_timestamp", config.CAMPAIGN_END.toISOString()) - .not("user_address", "is", null); - - if (tradesErr) { - console.error("[airdrop-points] Failed to fetch trades:", tradesErr.message); - return NextResponse.json({ error: "Failed to fetch trades" }, { status: 500 }); - } - - if (!trades || trades.length === 0) { - return NextResponse.json({ message: "No trades to process", processed: 0 }); - } - - const eligible = trades.filter( - (t) => t.user_address && t.user_address.toLowerCase() !== zapAddress, - ); - - const tradeIds = eligible.map((t) => t.id); - const { data: existing } = await supabase - .from("pl_points") - .select("metadata") - .eq("action", "buy") - .in("metadata->>trade_id", tradeIds.map(String)); - - const processedTradeIds = new Set( - (existing ?? []) - .map((r) => { - const meta = r.metadata as Record | null; - return meta?.trade_id != null ? String(meta.trade_id) : null; - }) - .filter(Boolean), - ); - - let buyCount = 0; - const inserts: Array<{ - address: string; - action: string; - points: number; - metadata: Record; - }> = []; - - for (const trade of eligible) { - if (processedTradeIds.has(String(trade.id))) continue; - - const address = trade.user_address!.toLowerCase(); - const buyPoints = computeBuyPoints(trade.reserve_amount, 0); - inserts.push({ - address, - action: "buy", - points: buyPoints, - metadata: { trade_id: trade.id }, - }); - buyCount++; - } - - if (inserts.length > 0) { - const { error: insertErr } = await supabase.from("pl_points").insert(inserts); - if (insertErr) { - console.error("[airdrop-points] Insert failed:", insertErr.message); - return NextResponse.json({ error: "Insert failed" }, { status: 500 }); - } - } - - console.info(`[airdrop-points] Processed ${buyCount} buys`); - return NextResponse.json({ - message: "Points synced", - processed: { buys: buyCount }, - }); -} - -export async function GET(req: Request) { - return handler(req); -} - -export async function POST(req: Request) { - return handler(req); -} diff --git a/src/app/api/cron/airdrop-price/route.ts b/src/app/api/cron/airdrop-price/route.ts deleted file mode 100644 index 1c3f34af..00000000 --- a/src/app/api/cron/airdrop-price/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Daily price snapshot cron (#890) - * - * Records PLOT USD price, circulating supply, and mcap for TWAP calculation. - * Schedule: once/day at midnight UTC (0 0 * * *) - */ - -import { NextResponse } from "next/server"; -import { formatUnits } from "viem"; -import { createServerClient } from "../../../../../lib/supabase"; -import { getPlotUsdPrice } from "../../../../../lib/usd-price"; -import { publicClient } from "../../../../../lib/rpc"; -import { PLOT_TOKEN, PLOT_MAX_SUPPLY } from "../../../../../lib/contracts/constants"; -import { erc20Abi } from "../../../../../lib/price"; - -function verifyCron(req: Request): boolean { - const secret = process.env.CRON_SECRET; - if (!secret) { - return process.env.NODE_ENV !== "production"; - } - const authHeader = req.headers.get("authorization"); - return authHeader === `Bearer ${secret}`; -} - -export async function GET(req: Request) { - if (!verifyCron(req)) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - const todayUtc = new Date().toISOString().slice(0, 10); - - // Idempotency: skip if today already has an entry - const { data: existing } = await supabase - .from("pl_daily_prices") - .select("id") - .eq("recorded_at", todayUtc) - .limit(1) - .single(); - - if (existing) { - return NextResponse.json({ skipped: true, reason: "Entry already exists for today" }); - } - - // Fetch price — skip snapshot entirely if price fetch fails - const priceUsd = await getPlotUsdPrice(true); - if (priceUsd === null) { - console.error("[airdrop-price] Price fetch returned null — skipping snapshot for", todayUtc); - return NextResponse.json({ skipped: true, reason: "Price fetch failed" }, { status: 200 }); - } - - // Fetch circulating supply from on-chain - let supplyFormatted: number; - try { - const totalSupplyRaw = await publicClient.readContract({ - address: PLOT_TOKEN, - abi: erc20Abi, - functionName: "totalSupply", - }) as bigint; - supplyFormatted = Number(formatUnits(totalSupplyRaw, 18)); - } catch (err) { - console.error("[airdrop-price] Failed to fetch supply:", err); - return NextResponse.json({ skipped: true, reason: "Supply fetch failed" }, { status: 200 }); - } - - // Store FDV (price × max supply) instead of MCap for milestone tracking - const fdvUsd = priceUsd * PLOT_MAX_SUPPLY; - - const { error } = await supabase.from("pl_daily_prices").insert({ - recorded_at: todayUtc, - price_usd: priceUsd, - supply: supplyFormatted, - mcap_usd: fdvUsd, // column stores FDV (price × max supply) - }); - - if (error) { - console.error("[airdrop-price] Insert failed:", error.message); - return NextResponse.json({ error: "Insert failed" }, { status: 500 }); - } - - console.info(`[airdrop-price] Snapshot recorded: date=${todayUtc} price=${priceUsd} supply=${supplyFormatted} fdv=${fdvUsd}`); - return NextResponse.json({ recorded: true, date: todayUtc, priceUsd, supply: supplyFormatted, fdvUsd }); -} diff --git a/src/app/api/cron/airdrop-weekly/route.ts b/src/app/api/cron/airdrop-weekly/route.ts deleted file mode 100644 index 3eab1f14..00000000 --- a/src/app/api/cron/airdrop-weekly/route.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Weekly stats snapshot cron (#891) - * - * Aggregates campaign stats for the weekly recap display. - * Schedule: Monday midnight UTC (0 0 * * 1) - */ - -import { NextResponse } from "next/server"; -import { createServerClient } from "../../../../../lib/supabase"; -import { AIRDROP_CONFIG } from "../../../../../lib/airdrop/config"; - -function verifyCron(req: Request): boolean { - const secret = process.env.CRON_SECRET; - if (!secret) { - return process.env.NODE_ENV !== "production"; - } - const authHeader = req.headers.get("authorization"); - return authHeader === `Bearer ${secret}`; -} - -export async function GET(req: Request) { - if (!verifyCron(req)) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const supabase = createServerClient(); - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); - } - - // Calculate week number and date range (Mon–Sun) - const now = new Date(); - const campaignStart = AIRDROP_CONFIG.CAMPAIGN_START; - const msElapsed = now.getTime() - campaignStart.getTime(); - const weekNumber = Math.floor(msElapsed / (7 * 86400000)) + 1; - - // Week boundaries: previous Monday to Sunday - const weekEnd = new Date(now); - weekEnd.setUTCHours(0, 0, 0, 0); - const weekStart = new Date(weekEnd.getTime() - 7 * 86400000); - - const weekStartStr = weekStart.toISOString().slice(0, 10); - const weekEndStr = weekEnd.toISOString().slice(0, 10); - - // Idempotency: skip if this week already has an entry - const { data: existing } = await supabase - .from("pl_weekly_snapshots") - .select("id") - .eq("week_number", weekNumber) - .limit(1) - .single(); - - if (existing) { - return NextResponse.json({ skipped: true, reason: `Week ${weekNumber} already recorded` }); - } - - // Aggregate stats for the past week - const [storiesRes, buysRes, referralsRes, plEarnedRes, mcapStartRes, mcapEndRes] = - await Promise.all([ - // New storylines created this week - supabase - .from("storylines") - .select("id", { count: "exact", head: true }) - .gte("created_at", weekStartStr) - .lt("created_at", weekEndStr), - - // Buy events (pl_points where action = 'buy') - supabase - .from("pl_points") - .select("id", { count: "exact", head: true }) - .eq("action", "buy") - .gte("created_at", weekStartStr) - .lt("created_at", weekEndStr), - - // New referrals - supabase - .from("pl_referrals") - .select("id", { count: "exact", head: true }) - .gte("created_at", weekStartStr) - .lt("created_at", weekEndStr), - - // Total PL earned this week - supabase - .from("pl_points") - .select("points") - .gte("created_at", weekStartStr) - .lt("created_at", weekEndStr), - - // MCap at week start (earliest price entry in the week) - supabase - .from("pl_daily_prices") - .select("mcap_usd") - .gte("recorded_at", weekStartStr) - .lt("recorded_at", weekEndStr) - .order("recorded_at", { ascending: true }) - .limit(1) - .single(), - - // MCap at week end (latest price entry in the week) - supabase - .from("pl_daily_prices") - .select("mcap_usd") - .gte("recorded_at", weekStartStr) - .lt("recorded_at", weekEndStr) - .order("recorded_at", { ascending: false }) - .limit(1) - .single(), - ]); - - const newStories = storiesRes.count ?? 0; - const tokenBuys = buysRes.count ?? 0; - const newReferrals = referralsRes.count ?? 0; - const totalPlEarned = (plEarnedRes.data ?? []).reduce((sum, r) => sum + r.points, 0); - const mcapStart = mcapStartRes.data?.mcap_usd ?? null; - const mcapEnd = mcapEndRes.data?.mcap_usd ?? null; - - const { error } = await supabase.from("pl_weekly_snapshots").insert({ - week_number: weekNumber, - week_start: weekStartStr, - new_stories: newStories, - token_buys: tokenBuys, - new_referrals: newReferrals, - mcap_start: mcapStart, - mcap_end: mcapEnd, - total_pl_earned: totalPlEarned, - }); - - if (error) { - console.error("[airdrop-weekly] Insert failed:", error.message); - return NextResponse.json({ error: "Insert failed" }, { status: 500 }); - } - - console.info(`[airdrop-weekly] Week ${weekNumber} snapshot recorded: stories=${newStories} buys=${tokenBuys} referrals=${newReferrals} pl=${totalPlEarned}`); - return NextResponse.json({ - recorded: true, - weekNumber, - weekStart: weekStartStr, - newStories, - tokenBuys, - newReferrals, - mcapStart, - mcapEnd, - totalPlEarned, - }); -} diff --git a/src/app/api/cron/backfill/route.ts b/src/app/api/cron/backfill/route.ts index 7cacbfc6..919787ad 100644 --- a/src/app/api/cron/backfill/route.ts +++ b/src/app/api/cron/backfill/route.ts @@ -8,7 +8,6 @@ import { hashContent } from "../../../../../lib/content"; import { detectWriterType } from "../../../../../lib/contracts/erc8004"; import { reconcileStorylinePlotCount } from "../../../../../lib/reconcile"; import { notifyNewPlot, notifyNewStoryline } from "../../../../../lib/notifications.server"; -import { awardWritePoints } from "../../../../../lib/airdrop/award"; import type { Database } from "../../../../../lib/supabase"; const IPFS_GATEWAY = "https://ipfs.filebase.io/ipfs/"; @@ -173,9 +172,6 @@ export async function GET(req: Request) { // Notify users about the new storyline const args = decoded.args as { storylineId: bigint; title: string; writer: `0x${string}` }; notifyNewStoryline(Number(args.storylineId), args.title, args.writer).catch(() => {}); - // Award airdrop write points (non-blocking) - const scBlockTs = await getCachedBlockTimestamp(log.blockNumber!); - awardWritePoints(args.writer, Number(args.storylineId), scBlockTs ? new Date(Number(scBlockTs) * 1000) : undefined).catch(() => {}); } } else if (decoded.eventName === "PlotChained") { const failed = await processPlotChained( diff --git a/src/app/api/index/storyline/route.ts b/src/app/api/index/storyline/route.ts index 392aba0c..2a2f0e26 100644 --- a/src/app/api/index/storyline/route.ts +++ b/src/app/api/index/storyline/route.ts @@ -13,7 +13,6 @@ import { hashContent } from "../../../../../lib/content"; import { GENRES, LANGUAGES, CONTENT_TYPES } from "../../../../../lib/genres"; import type { Database } from "../../../../../lib/supabase"; import { reconcileStorylinePlotCount } from "../../../../../lib/reconcile"; -import { awardWritePoints } from "../../../../../lib/airdrop/award"; const IPFS_GATEWAY = "https://ipfs.filebase.io/ipfs/"; const IPFS_TIMEOUT_MS = 10_000; @@ -224,8 +223,5 @@ export async function POST(req: Request) { // Reconcile plot_count from actual plots rows (prevents genesis double-count) await reconcileStorylinePlotCount(supabase, Number(storylineId)); - // Award airdrop write points (non-blocking, using on-chain timestamp) - awardWritePoints(writer, Number(storylineId), new Date(Number(blockTimestamp) * 1000)).catch(() => {}); - return NextResponse.json({ success: true }); } diff --git a/src/app/api/ratings/route.ts b/src/app/api/ratings/route.ts index cfb13ea1..2545966c 100644 --- a/src/app/api/ratings/route.ts +++ b/src/app/api/ratings/route.ts @@ -4,7 +4,6 @@ import { publicClient } from "../../../../lib/rpc"; import { createServerClient, supabase } from "../../../../lib/supabase"; import { erc20Abi } from "../../../../lib/price"; import { STORY_FACTORY } from "../../../../lib/contracts/constants"; -import { awardRatePoints } from "../../../../lib/airdrop/award"; const MAX_COMMENT_LENGTH = 500; @@ -190,8 +189,5 @@ export async function POST(req: NextRequest) { return error(`Database error: ${upsertError.message}`, 500); } - // Award airdrop rate points (non-blocking) - awardRatePoints(raterAddress, storylineId).catch(() => {}); - return NextResponse.json({ success: true }); } diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 28b993fe..0139d419 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -35,15 +35,6 @@ export default function PrivacyPage() {

Farcaster Profile Data

If your wallet is linked to a Farcaster account, we cache your public Farcaster profile (username, display name, profile picture) for display purposes. This data is publicly available via the Farcaster protocol.

-

Airdrop Campaign Data

-

If you participate in the PLOT airdrop campaign, we store:

-
    -
  • Your wallet address and earned points
  • -
  • Referral relationships (which wallet referred which)
  • -
  • Daily check-in streak data
  • -
  • Referral codes
  • -
-

Story Ratings and Comments

If you rate or comment on a story, your wallet address and the rating/comment are stored in our database.

@@ -64,7 +55,7 @@ export default function PrivacyPage() {

6. Data Retention

  • On-chain data and IPFS content are permanent by design and cannot be deleted
  • -
  • Database records (ratings, airdrop points, cached profiles) are retained indefinitely
  • +
  • Database records (ratings, cached profiles) are retained indefinitely
  • There is no account deletion process because there are no accounts — only wallet addresses
diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 95395406..e80ddadf 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -4,8 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { WagmiProvider } from "wagmi"; import { RainbowKitProvider, type Theme } from "@rainbow-me/rainbowkit"; import { config } from "../../lib/wagmi"; -import { useState, Suspense } from "react"; -import { useReferralCapture } from "../hooks/useReferralCapture"; +import { useState } from "react"; import { FrameProvider } from "../components/FrameProvider"; import "@rainbow-me/rainbowkit/styles.css"; @@ -66,11 +65,6 @@ const plotlinkTheme: Theme = { }, }; -function ReferralCapture() { - useReferralCapture(); - return null; -} - export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( () => @@ -105,9 +99,6 @@ export function Providers({ children }: { children: React.ReactNode }) { }} > - - - {children} diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index 1588b4c0..a1178c33 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -9,7 +9,6 @@ import { DonateWidget } from "../../../components/DonateWidget"; import { RatingWidget } from "../../../components/RatingWidget"; import { RatingSummary } from "../../../components/RatingSummary"; import { ShareButtons } from "../../../components/ShareButtons"; -import { StorylineSprintBanner } from "../../../components/airdrop/StorylineSprintBanner"; import { StoryContent } from "../../../components/StoryContent"; import { ReadingModeWrapper } from "../../../components/ReadingModeWrapper"; import { getTokenPrice, getCreatorEarnings, type TokenPriceInfo } from "../../../../lib/price"; @@ -240,8 +239,6 @@ export default async function StoryPage({ params }: { params: Params }) {
- - {/* Sidebar — desktop only */} diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx index 733c6b80..d7ccddbb 100644 --- a/src/app/terms/page.tsx +++ b/src/app/terms/page.tsx @@ -18,7 +18,6 @@ export default function TermsPage() {
  • Bonding curve token creation via Mint Club V2 (third-party protocol)
  • IPFS storage of story content via Filebase (third-party provider)
  • An AI writing assistant tool (PlotLink OWS) for local use
  • -
  • An airdrop campaign with conditional token distribution
  • PlotLink does NOT:

      @@ -93,28 +92,25 @@ export default function TermsPage() {

    PlotLink earns no fees from token minting or burning. Creation fees are paid to Mint Club.

    -

    12. Airdrop Campaign

    -

    The PLOT Big or Nothing Airdrop is a conditional distribution. Participation does not guarantee any token distribution. The airdrop pool may be partially or fully burned based on market conditions. PlotLink makes no guarantees about token value or distribution outcomes. The airdrop is not compensation, income, or a return on investment.

    - -

    13. AI Writing Tool

    +

    12. AI Writing Tool

    PlotLink OWS is a local application that runs on your computer. It connects to third-party AI providers (Anthropic, OpenAI, etc.) using your own API keys. PlotLink does not process, store, or have access to your AI conversations or API keys.

    -

    14. Third-Party Services

    +

    13. Third-Party Services

    PlotLink integrates with third-party services including but not limited to Mint Club, Filebase (IPFS), Base network, and Farcaster. PlotLink is not responsible for the availability, accuracy, or conduct of these services.

    -

    15. No Warranty

    +

    14. No Warranty

    PlotLink is provided “as is” without warranties of any kind, express or implied. We do not guarantee uninterrupted access, error-free operation, or the accuracy of any displayed data (including prices, market caps, or token metrics).

    -

    16. Limitation of Liability

    +

    15. Limitation of Liability

    To the maximum extent permitted by law, PlotLink and its contributors shall not be liable for any indirect, incidental, special, or consequential damages arising from your use of the service, including but not limited to loss of funds, tokens, or data.

    -

    17. Modification

    +

    16. Modification

    We may update these terms at any time. Continued use of PlotLink after changes constitutes acceptance.

    -

    18. Governing Law

    +

    17. Governing Law

    These terms are governed by the laws applicable to the user's jurisdiction. PlotLink does not operate as a registered entity in any specific jurisdiction.

    -

    19. Contact

    +

    18. Contact

    For questions about these terms, open an issue at github.com/realproject7/plotlink.

    diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index a2302ba9..68161ab4 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -20,7 +20,6 @@ export function NavBar() { { href: "/create", label: "Create" }, { href: dashboardHref, label: "Dashboard" }, { href: "/agents", label: "AI Writer" }, - // { href: "/airdrop", label: "Airdrop" }, // Hidden until campaign launch — page still accessible via direct URL { href: "/token", label: "$PLOT" }, ]; diff --git a/src/components/ReferralInput.tsx b/src/components/ReferralInput.tsx deleted file mode 100644 index 06918cd1..00000000 --- a/src/components/ReferralInput.tsx +++ /dev/null @@ -1,109 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useAccount, useSignMessage } from "wagmi"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { REFERRAL_STORAGE_KEY } from "../hooks/useReferralCapture"; - -/** - * "Who referred you?" input — one-time, non-editable once submitted. - * Only visible when user has no referrer set. Pre-fills from localStorage ref capture. - */ -export function ReferralInput() { - const { address, isConnected } = useAccount(); - const { signMessageAsync } = useSignMessage(); - const queryClient = useQueryClient(); - const [code, setCode] = useState(() => { - if (typeof window !== "undefined") { - return localStorage.getItem(REFERRAL_STORAGE_KEY) ?? ""; - } - return ""; - }); - const [error, setError] = useState(""); - const [submitting, setSubmitting] = useState(false); - - // Check if user already has a referrer - const { data: referrerData, isLoading } = useQuery({ - queryKey: ["my-referrer", address], - queryFn: async () => { - const res = await fetch( - `/api/airdrop/register-referral?address=${address!.toLowerCase()}`, - ); - if (!res.ok) return null; - const data = await res.json(); - if (!data.referrer) return null; - return { referrer: data.referrer as string, displayName: data.displayName as string }; - }, - enabled: isConnected && !!address, - staleTime: Infinity, - }); - - if (!isConnected || isLoading) return null; - - // Already has a referrer — show read-only - if (referrerData) { - return ( -
    -
    Referred by
    -
    - {referrerData.displayName} -
    -
    - ); - } - - const handleSubmit = async () => { - if (!code.trim() || !address) return; - setError(""); - setSubmitting(true); - - try { - const message = `${address}\n\nRegister referral code: ${code.trim()}\nTimestamp: ${new Date().toISOString()}`; - const signature = await signMessageAsync({ message }); - - const res = await fetch("/api/airdrop/register-referral", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message, signature, referralCode: code.trim() }), - }); - - const data = await res.json(); - if (!res.ok) { - setError(data.error ?? "Registration failed"); - return; - } - - // Clear localStorage and refresh query - localStorage.removeItem(REFERRAL_STORAGE_KEY); - queryClient.invalidateQueries({ queryKey: ["my-referrer", address] }); - } catch { - setError("Signature rejected or failed"); - } finally { - setSubmitting(false); - } - }; - - return ( -
    -
    Who referred you?
    -
    - setCode(e.target.value)} - placeholder="Enter referral code" - className="bg-surface border-border text-foreground placeholder:text-muted flex-1 rounded border px-3 py-1.5 text-sm font-mono focus:border-accent focus:outline-none" - /> - -
    - {error &&
    {error}
    } -
    - ); -} diff --git a/src/components/ShareButtons.tsx b/src/components/ShareButtons.tsx index 80f5bd2d..2cf105dd 100644 --- a/src/components/ShareButtons.tsx +++ b/src/components/ShareButtons.tsx @@ -2,7 +2,6 @@ import { useState, useCallback } from "react"; import { usePlatformDetection } from "../hooks/usePlatformDetection"; -import { useReferralCode } from "../hooks/useReferralCode"; interface ShareButtonsProps { storylineId: number; @@ -12,12 +11,9 @@ interface ShareButtonsProps { export function ShareButtons({ storylineId, title }: ShareButtonsProps) { const { platform } = usePlatformDetection(); const [copied, setCopied] = useState(false); - const { data: referralCode } = useReferralCode(); const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; - const storyUrl = referralCode - ? `${appUrl}/story/${storylineId}?ref=${encodeURIComponent(referralCode)}` - : `${appUrl}/story/${storylineId}`; + const storyUrl = `${appUrl}/story/${storylineId}`; // Rotate through share texts to keep shares feeling fresh const shareTexts = [ `"${title}" — a tokenised story where every plot is tradeable. Read it, write the next chapter, earn royalties`, diff --git a/src/components/airdrop/ActivationFlow.test.tsx b/src/components/airdrop/ActivationFlow.test.tsx deleted file mode 100644 index b472c1e4..00000000 --- a/src/components/airdrop/ActivationFlow.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -// @vitest-environment jsdom -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; - -const mockSignMessageAsync = vi.fn().mockResolvedValue("0xmocksig"); -const mockHandleInboundReferral = vi.fn().mockResolvedValue(undefined); - -vi.mock("wagmi", () => ({ - useAccount: () => ({ address: "0xABC123", chainId: 8453 }), - useSignMessage: () => ({ signMessageAsync: mockSignMessageAsync }), -})); - -vi.mock("siwe", () => ({ - SiweMessage: class { - prepareMessage() { return "mock-siwe-message"; } - }, -})); - -vi.mock("../../../lib/airdrop/activation-helpers", () => ({ - handleInboundReferral: (...args: unknown[]) => mockHandleInboundReferral(...args), -})); - -let activationStatus = { x_handle_confirmed_at: null as string | null, x_follow_at: null as string | null, fc_verified_at: null, activated_at: null }; - -beforeEach(() => { - activationStatus = { x_handle_confirmed_at: null, x_follow_at: null, fc_verified_at: null, activated_at: null }; - mockSignMessageAsync.mockResolvedValue("0xmocksig"); - mockHandleInboundReferral.mockResolvedValue(undefined); - vi.stubGlobal("fetch", vi.fn(async () => ({ - ok: true, - status: 200, - json: () => Promise.resolve(activationStatus), - } as Response))); -}); - -afterEach(() => { - vi.clearAllMocks(); - vi.unstubAllGlobals(); -}); - -import { ActivationFlow } from "./ActivationFlow"; - -describe("ActivationFlow", () => { - it("renders step 1 (SIWE sign) when not activated", async () => { - render(); - await waitFor(() => { - expect(screen.getByText("Sign & Activate")).toBeDefined(); - }); - }); - - it("skips to step 3 (missions) when x_handle already confirmed", async () => { - activationStatus = { x_handle_confirmed_at: "2026-07-01", x_follow_at: null, fc_verified_at: null, activated_at: null }; - render(); - await waitFor(() => { - expect(screen.getByText(/Follow @plotlinkxyz/i)).toBeDefined(); - }); - }); - - it("shows FC follow as optional in step 3", async () => { - activationStatus = { x_handle_confirmed_at: "2026-07-01", x_follow_at: null, fc_verified_at: null, activated_at: null }; - render(); - await waitFor(() => { - expect(screen.getByText(/optional/i)).toBeDefined(); - }); - }); - - it("shows X follow as done when x_follow_at present", async () => { - activationStatus = { x_handle_confirmed_at: "2026-07-01", x_follow_at: "2026-07-02", fc_verified_at: null, activated_at: null }; - render(); - await waitFor(() => { - const dones = screen.getAllByText(/Done/i); - expect(dones.length).toBeGreaterThan(0); - }); - }); - - it("clicking Sign & Activate calls signMessageAsync and handleInboundReferral", async () => { - render(); - const btns = await waitFor(() => screen.getAllByText("Sign & Activate")); - await userEvent.click(btns[0]); - - await waitFor(() => { - expect(mockSignMessageAsync).toHaveBeenCalledWith({ message: "mock-siwe-message" }); - }); - - await waitFor(() => { - expect(mockHandleInboundReferral).toHaveBeenCalledWith("mock-siwe-message", "0xmocksig"); - }); - - await waitFor(() => { - expect(screen.getByPlaceholderText(/@handle/i)).toBeDefined(); - }); - }); - - it("shows error when signature rejected by user", async () => { - mockSignMessageAsync.mockRejectedValue(new Error("User rejected the request")); - render(); - const btns = await waitFor(() => screen.getAllByText("Sign & Activate")); - await userEvent.click(btns[0]); - await waitFor(() => { - expect(screen.getByText(/Signature rejected/i)).toBeDefined(); - }); - }); - - it("shows generic error on non-rejection sign failure", async () => { - mockSignMessageAsync.mockRejectedValue(new Error("Unknown wallet error")); - render(); - const btns = await waitFor(() => screen.getAllByText("Sign & Activate")); - await userEvent.click(btns[0]); - await waitFor(() => { - expect(screen.getByText(/Failed to sign/i)).toBeDefined(); - }); - }); -}); diff --git a/src/components/airdrop/ActivationFlow.tsx b/src/components/airdrop/ActivationFlow.tsx deleted file mode 100644 index c4249031..00000000 --- a/src/components/airdrop/ActivationFlow.tsx +++ /dev/null @@ -1,391 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback } from "react"; -import { useAccount, useSignMessage } from "wagmi"; -import { SiweMessage } from "siwe"; -import { handleInboundReferral } from "../../../lib/airdrop/activation-helpers"; - -type StepState = "idle" | "active" | "done"; - -interface ActivationStatus { - x_handle_confirmed_at: string | null; - x_follow_at: string | null; - fc_verified_at: string | null; - activated_at: string | null; -} - -function StepIndicator({ state, label }: { state: StepState; label: string }) { - return ( -
    - {state === "done" && } - {state === "active" && } - {state === "idle" && } - {label} -
    - ); -} - -interface ActivationFlowProps { - onActivated?: () => void; -} - -export function ActivationFlow({ onActivated }: ActivationFlowProps) { - const { address, chainId } = useAccount(); - const { signMessageAsync } = useSignMessage(); - - const [siweMessage, setSiweMessage] = useState(null); - const [signature, setSignature] = useState(null); - const [step, setStep] = useState<1 | 2 | 3>(1); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - const [xUsername, setXUsername] = useState(""); - const [xPreview, setXPreview] = useState<{ display_name: string; avatar_url: string; follower_count: number } | null>(null); - const [xConfirmed, setXConfirmed] = useState(false); - const [xFollowed, setXFollowed] = useState(false); - const [fcVerified, setFcVerified] = useState(false); - const [fcUsername, setFcUsername] = useState(""); - - useEffect(() => { - if (!address) return; - fetch(`/api/airdrop/activation-status?address=${address.toLowerCase()}`) - .then(r => r.json()) - .then((data: ActivationStatus) => { - if (data.x_handle_confirmed_at) { - setXConfirmed(true); - if (data.x_follow_at) setXFollowed(true); - if (data.fc_verified_at) setFcVerified(true); - if (data.activated_at) { - setStep(3); - setXFollowed(true); - } else if (data.x_follow_at) { - setStep(3); - } else { - setStep(3); - } - } - }) - .catch(() => {}); - }, [address]); - - const buildSiweMessage = useCallback(() => { - if (!address || !chainId) return null; - const msg = new SiweMessage({ - domain: "plotlink.xyz", - address, - statement: "PlotLink Buy-Back Sprint activation", - uri: "https://plotlink.xyz/airdrop", - version: "1", - chainId, - nonce: Math.random().toString(36).slice(2, 10), - issuedAt: new Date().toISOString(), - }); - return msg.prepareMessage(); - }, [address, chainId]); - - const handleSign = async () => { - setError(null); - setLoading(true); - try { - const msg = buildSiweMessage(); - if (!msg) throw new Error("Wallet not ready"); - const sig = await signMessageAsync({ message: msg }); - setSiweMessage(msg); - setSignature(sig); - - await handleInboundReferral(msg, sig); - - if (xConfirmed) { - setStep(3); - } else { - setStep(2); - } - } catch (err) { - if (err instanceof Error && err.message.includes("User rejected")) { - setError("Signature rejected — please try again."); - } else { - setError("Failed to sign. Please try again."); - } - } finally { - setLoading(false); - } - }; - - const handleVerifyX = async () => { - if (!siweMessage || !signature || !xUsername.trim()) return; - setError(null); - setLoading(true); - try { - const res = await fetch("/api/airdrop/verify-x-handle", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: siweMessage, signature, username: xUsername.trim() }), - }); - if (res.status === 401) { - setError("Signature expired. Please re-sign."); - setSiweMessage(null); - setSignature(null); - setStep(1); - return; - } - if (res.status === 404) { - setError("X user not found. Check the handle and try again."); - return; - } - if (!res.ok) { - setError("Couldn't verify right now. Try again shortly."); - return; - } - const data = await res.json(); - setXPreview(data); - } catch { - setError("Couldn't verify right now. Try again shortly."); - } finally { - setLoading(false); - } - }; - - const handleConfirmX = async () => { - if (!siweMessage || !signature || !xUsername.trim()) return; - setError(null); - setLoading(true); - try { - const res = await fetch("/api/airdrop/confirm-x-handle", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: siweMessage, signature, username: xUsername.trim() }), - }); - if (res.status === 401) { - setError("Signature expired. Please re-sign."); - setSiweMessage(null); - setSignature(null); - setStep(1); - return; - } - if (res.status === 409) { - setError("This X account is already linked to another wallet."); - return; - } - if (!res.ok) { - setError("Couldn't confirm right now. Try again shortly."); - return; - } - setXConfirmed(true); - setXPreview(null); - setStep(3); - } catch { - setError("Couldn't confirm right now. Try again shortly."); - } finally { - setLoading(false); - } - }; - - const handleXFollow = async () => { - window.open("https://x.com/intent/follow?screen_name=plotlinkxyz", "_blank"); - if (!siweMessage || !signature) return; - setError(null); - setLoading(true); - try { - const res = await fetch("/api/airdrop/x-follow-click", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: siweMessage, signature }), - }); - if (res.status === 401) { - setError("Signature expired. Please re-sign."); - setSiweMessage(null); - setSignature(null); - setStep(1); - return; - } - if (res.ok) { - setXFollowed(true); - const data = await res.json(); - if (data.activated) onActivated?.(); - } - } catch { - // non-blocking - } finally { - setLoading(false); - } - }; - - const handleVerifyFc = async () => { - if (!siweMessage || !signature || !fcUsername.trim()) return; - setError(null); - setLoading(true); - try { - const res = await fetch("/api/airdrop/verify-fc", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: siweMessage, signature, username: fcUsername.trim() }), - }); - if (res.status === 401) { - setError("Signature expired. Please re-sign."); - setSiweMessage(null); - setSignature(null); - setStep(1); - return; - } - if (res.status === 404) { - setError("Couldn't find that FC user."); - return; - } - if (res.status === 422) { - setError("Looks like you don't follow @plotlink yet — follow and click verify again."); - return; - } - if (res.status === 409) { - setError("This Farcaster account is already linked."); - return; - } - if (res.status === 502) { - setError("Farcaster verification unavailable right now. You can skip this step."); - return; - } - if (res.ok) { - setFcVerified(true); - } - } catch { - setError("Verification failed. You can skip this step."); - } finally { - setLoading(false); - } - }; - - const siweStep: StepState = (siweMessage && signature) ? "done" : step === 1 ? "active" : "idle"; - const xStep: StepState = xConfirmed ? "done" : step === 2 ? "active" : "idle"; - const missionStep: StepState = xFollowed ? "done" : step === 3 ? "active" : "idle"; - - return ( -
    -
    - - - -
    - - {error && ( -
    - {error} -
    - )} - - {step === 1 && ( -
    -

    Sign a message to verify wallet ownership.

    - -
    - )} - - {step === 2 && ( -
    -

    Enter your X (Twitter) handle to verify your account.

    -
    - { setXUsername(e.target.value); setError(null); }} - placeholder="@handle" - className="bg-background border-border text-foreground flex-1 rounded border px-3 py-2 text-xs" - /> - -
    - - {xPreview && ( -
    -
    - {xPreview.avatar_url && ( - // eslint-disable-next-line @next/next/no-img-element - - )} -
    -
    {xPreview.display_name}
    -
    {xPreview.follower_count.toLocaleString()} followers
    -
    -
    - -
    - )} -
    - )} - - {step === 3 && ( -
    -
    -
    - Follow @plotlinkxyz on X - {xFollowed ? ( - ✓ Done - ) : ( - - )} -
    - -
    -
    - Follow @plotlink on Farcaster (optional) - {fcVerified && ✓ Verified} -
    - {!fcVerified && ( -
    - { setFcUsername(e.target.value); setError(null); }} - placeholder="FC username" - className="bg-background border-border text-foreground flex-1 rounded border px-3 py-1.5 text-xs" - /> - -
    - )} -
    -
    - - {!siweMessage && ( -
    -

    Session expired. Re-sign to continue.

    - -
    - )} -
    - )} -
    - ); -} diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx deleted file mode 100644 index ef3e0ca2..00000000 --- a/src/components/airdrop/CampaignHero.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; - -interface StatusData { - campaignStart: string; - campaignEnd: string; - timeRemainingDays: number; - timeElapsedPercent: number; - poolAmount: number; - currentFdv: number; - milestones: { - bronze: { mcap: number; reached: boolean }; - silver: { mcap: number; reached: boolean }; - gold: { mcap: number; reached: boolean }; - diamond: { mcap: number; reached: boolean }; - }; - activation_count: number; - eligible_activation_count: number; -} - -function formatMcap(n: number): string { - if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}K`; - return `$${n}`; -} - -function nextMilestone(milestones: StatusData["milestones"]): string { - if (!milestones.bronze.reached) return `Bronze (${formatMcap(milestones.bronze.mcap)})`; - if (!milestones.silver.reached) return `Silver (${formatMcap(milestones.silver.mcap)})`; - if (!milestones.gold.reached) return `Gold (${formatMcap(milestones.gold.mcap)})`; - if (!milestones.diamond.reached) return `Diamond (${formatMcap(milestones.diamond.mcap)})`; - return "Diamond reached"; -} - -export function CampaignHero() { - const { data } = useQuery({ - queryKey: ["airdrop-status"], - queryFn: async () => { - const res = await fetch("/api/airdrop/status"); - if (!res.ok) throw new Error("Failed to fetch status"); - return res.json(); - }, - staleTime: 60_000, - }); - - if (!data) { - return ( -
    -
    Loading campaign status...
    -
    - ); - } - - const dayNum = Math.ceil(data.timeElapsedPercent / 100 * 90) || 1; - - return ( -
    -
    - Buy-Back Sprint - Day {dayNum}/90 - FDV {formatMcap(data.currentFdv)} - Next: {nextMilestone(data.milestones)} - {data.activation_count > 0 && ( - {data.activation_count.toLocaleString()} activated - )} - {data.timeRemainingDays}d left -
    -
    - ); -} diff --git a/src/components/airdrop/ClaimCard.tsx b/src/components/airdrop/ClaimCard.tsx deleted file mode 100644 index 9bc5edfc..00000000 --- a/src/components/airdrop/ClaimCard.tsx +++ /dev/null @@ -1,225 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useAccount, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi"; -import { useQuery } from "@tanstack/react-query"; -import { formatUnits } from "viem"; -import { EXPLORER_URL } from "../../../lib/contracts/constants"; - -const MERKLE_CLAIM_ADDRESS = (process.env.NEXT_PUBLIC_MERKLE_CLAIM_ADDRESS ?? "") as `0x${string}`; -const FINAL_BURN_TX = process.env.NEXT_PUBLIC_AIRDROP_FINAL_BURN_TX ?? null; - -const MERKLE_CLAIM_ABI = [ - { type: "function", name: "claim", stateMutability: "nonpayable", inputs: [{ name: "amount", type: "uint256" }, { name: "proof", type: "bytes32[]" }], outputs: [] }, - { type: "function", name: "claimed", stateMutability: "view", inputs: [{ name: "", type: "address" }], outputs: [{ name: "", type: "bool" }] }, - { type: "function", name: "claimDeadline", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "uint256" }] }, -] as const; - -interface ClaimCardProps { - mode: "normal" | "final-burn"; - finalState?: "sub_bronze" | "zero_recipient" | null; -} - -export function ClaimCard({ mode, finalState }: ClaimCardProps) { - if (mode === "final-burn") { - return ; - } - return ; -} - -function FinalBurnCard({ finalState }: { finalState?: "sub_bronze" | "zero_recipient" | null }) { - const message = finalState === "sub_bronze" - ? "Campaign ended below Bronze milestone. All tokens burned." - : finalState === "zero_recipient" - ? "No eligible recipients. All tokens burned." - : "Campaign ended. Watch for Season 2."; - - return ( -
    -

    Campaign Complete

    -

    {message}

    - {FINAL_BURN_TX && ( - - View burn transaction - - )} -
    - ); -} - -function NormalClaimCard() { - const { address, isConnected } = useAccount(); - - if (!isConnected || !address) { - return ( -
    -

    Connect your wallet to check your claim.

    -
    - ); - } - - return ; -} - -function ClaimCardInner({ address }: { address: string }) { - const [txHash, setTxHash] = useState<`0x${string}` | null>(null); - const [now, setNow] = useState(() => Math.floor(Date.now() / 1000)); - - useEffect(() => { - const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000); - return () => clearInterval(id); - }, []); - - const { data: proofData, isLoading: proofLoading } = useQuery<{ - eligible: boolean; - amount: string | null; - proof: string[] | null; - }>({ - queryKey: ["airdrop-proof", address], - queryFn: async () => { - const res = await fetch(`/api/airdrop/proof?address=${address.toLowerCase()}`); - if (!res.ok) throw new Error("Failed to fetch proof"); - return res.json(); - }, - staleTime: Infinity, - }); - - const { data: projection } = useQuery<{ - buy_volume: number; - qualified_refs: number; - has_fc_bonus: boolean; - multiplier: number; - weighted_spend: number; - community_total: number; - }>({ - queryKey: ["airdrop-projection-claim", address], - queryFn: async () => { - const res = await fetch(`/api/airdrop/projection?address=${address.toLowerCase()}`); - if (!res.ok) return null; - return res.json(); - }, - staleTime: 60_000, - }); - - const { data: deadline } = useReadContract({ - address: MERKLE_CLAIM_ADDRESS || undefined, - abi: MERKLE_CLAIM_ABI, - functionName: "claimDeadline", - query: { enabled: !!MERKLE_CLAIM_ADDRESS }, - }); - - const { data: hasClaimed } = useReadContract({ - address: MERKLE_CLAIM_ADDRESS || undefined, - abi: MERKLE_CLAIM_ABI, - functionName: "claimed", - args: [address as `0x${string}`], - query: { enabled: !!proofData?.eligible && !!MERKLE_CLAIM_ADDRESS }, - }); - - const { writeContract, isPending: isClaiming } = useWriteContract(); - const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({ hash: txHash ?? undefined }); - - if (proofLoading) { - return ( -
    -

    Checking claim eligibility...

    -
    - ); - } - - if (!proofData?.eligible || !proofData.amount || !proofData.proof) { - return ( -
    -

    Campaign Complete

    -

    You were not eligible for this campaign.

    -
    - ); - } - - const amountFormatted = formatUnits(BigInt(proofData.amount), 18); - const amountDisplay = Number(amountFormatted).toLocaleString(undefined, { maximumFractionDigits: 2 }); - const alreadyClaimed = hasClaimed === true || isConfirmed; - - const deadlineTs = deadline ? Number(deadline) : null; - const pastDeadline = deadlineTs ? now > deadlineTs : false; - const timeLeft = deadlineTs && !pastDeadline ? deadlineTs - now : 0; - const daysLeft = Math.floor(timeLeft / 86400); - const hoursLeft = Math.floor((timeLeft % 86400) / 3600); - - const handleClaim = () => { - writeContract( - { - address: MERKLE_CLAIM_ADDRESS as `0x${string}`, - abi: MERKLE_CLAIM_ABI, - functionName: "claim", - args: [BigInt(proofData.amount!), proofData.proof! as `0x${string}`[]], - }, - { onSuccess: (hash) => setTxHash(hash) }, - ); - }; - - return ( -
    -

    Claim Your PLOT

    - - {projection && ( -
    -
    Live contribution breakdown (may not reflect final settlement)
    -
    -
    {projection.buy_volume.toLocaleString()} PLOT spent × {projection.multiplier.toFixed(1)}× multiplier
    -
    - ({projection.qualified_refs} refs{projection.has_fc_bonus ? " + FC bonus" : ""}) -
    -
    = {projection.weighted_spend.toLocaleString()} weighted spend
    -
    -
    Final claim amount below is from the on-chain settlement.
    -
    - )} - -
    -
    -
    Your claim
    -
    {amountDisplay} PLOT
    -
    - - {deadlineTs && !pastDeadline && ( -
    - Claim window: {daysLeft}d {hoursLeft}h remaining -
    - )} - - {pastDeadline && !alreadyClaimed && ( -
    Claim window closed.
    - )} - - {alreadyClaimed ? ( - - ) : ( - - )} -
    -
    - ); -} diff --git a/src/components/airdrop/ClaimPanel.tsx b/src/components/airdrop/ClaimPanel.tsx deleted file mode 100644 index b8e4e51a..00000000 --- a/src/components/airdrop/ClaimPanel.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client"; - -import { ClaimCard } from "./ClaimCard"; - -export function ClaimPanel() { - return ; -} diff --git a/src/components/airdrop/ContributionPanel.tsx b/src/components/airdrop/ContributionPanel.tsx deleted file mode 100644 index 7a0402cf..00000000 --- a/src/components/airdrop/ContributionPanel.tsx +++ /dev/null @@ -1,131 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useAccount } from "wagmi"; - -interface ProjectionData { - address: string; - buy_volume: number; - qualified_refs: number; - has_fc_bonus: boolean; - multiplier: number; - weighted_spend: number; - community_total: number; - projected_share: { - bronze: number; - silver: number; - gold: number; - diamond: number; - }; -} - -function Stat({ label, value, accent }: { label: string; value: string; accent?: boolean }) { - return ( -
    -
    {label}
    -
    {value}
    -
    - ); -} - -export function ContributionPanel() { - const { address, isConnected } = useAccount(); - const [fetchState, setFetchState] = useState<{ - data: ProjectionData | null; - error: string | null; - done: boolean; - }>({ data: null, error: null, done: !isConnected }); - - useEffect(() => { - if (!isConnected || !address) return; - - let cancelled = false; - fetch(`/api/airdrop/projection?address=${address.toLowerCase()}`) - .then(res => { - if (res.status === 404) return null; - if (!res.ok) throw new Error("Failed to load"); - return res.json(); - }) - .then(d => { if (!cancelled) setFetchState({ data: d, error: null, done: true }); }) - .catch(() => { if (!cancelled) setFetchState({ data: null, error: "Failed to load contribution data.", done: true }); }); - - return () => { cancelled = true; setFetchState({ data: null, error: null, done: false }); }; - }, [isConnected, address]); - - const { data, error } = fetchState; - const loading = isConnected && !fetchState.done; - - if (loading) { - return ( -
    -

    Loading contribution data...

    -
    - ); - } - - if (error) { - return ( -
    -

    {error}

    -
    - ); - } - - if (!data) { - return ( -
    -

    Your Contribution

    -

    Buy PLOT tokens to start earning your share of the airdrop pool.

    -
    - ); - } - - const currentShare = data.community_total > 0 - ? (data.weighted_spend / data.community_total * 100).toFixed(2) - : "0.00"; - - return ( -
    -

    Your Contribution

    - -
    - - - - - - -
    - -
    -
    Projected Share by Milestone
    -
    -
    -
    Bronze
    -
    {Math.round(data.projected_share.bronze).toLocaleString()}
    -
    PLOT
    -
    -
    -
    Silver
    -
    {Math.round(data.projected_share.silver).toLocaleString()}
    -
    PLOT
    -
    -
    -
    Gold
    -
    {Math.round(data.projected_share.gold).toLocaleString()}
    -
    PLOT
    -
    -
    -
    Diamond
    -
    {Math.round(data.projected_share.diamond).toLocaleString()}
    -
    PLOT
    -
    -
    -
    -
    - ); -} diff --git a/src/components/airdrop/Leaderboard.tsx b/src/components/airdrop/Leaderboard.tsx deleted file mode 100644 index ef7e8722..00000000 --- a/src/components/airdrop/Leaderboard.tsx +++ /dev/null @@ -1,140 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useAccount } from "wagmi"; -import { useQuery } from "@tanstack/react-query"; - -interface LeaderboardEntry { - rank: number; - address: string; - username: string | null; - totalPoints: number; - sharePercent: number; -} - -interface LeaderboardData { - entries: LeaderboardEntry[]; - userRank: number | null; - totalParticipants: number; - page: number; - totalPages: number; - limit: number; -} - -function truncateAddress(addr: string) { - return `${addr.slice(0, 6)}...${addr.slice(-4)}`; -} - -export function Leaderboard() { - const { address, isConnected } = useAccount(); - const [page, setPage] = useState(1); - - const { data, isLoading } = useQuery({ - queryKey: ["airdrop-leaderboard", address, page], - queryFn: async () => { - const params = new URLSearchParams({ page: String(page), limit: "20" }); - if (address) params.set("address", address.toLowerCase()); - const res = await fetch(`/api/airdrop/leaderboard?${params}`); - if (!res.ok) throw new Error("Failed to fetch leaderboard"); - return res.json(); - }, - staleTime: 60_000, - refetchInterval: 60_000, - }); - - if (isLoading || !data) { - return ( -
    -
    Loading leaderboard...
    -
    - ); - } - - if (data.entries.length === 0 && data.totalParticipants === 0) { - return ( -
    -

    Leaderboard

    -
    No participants yet.
    -
    - ); - } - - const userAddr = address?.toLowerCase(); - const onCurrentPage = userAddr && data.entries.some((e) => e.address.toLowerCase() === userAddr); - - return ( -
    -

    Leaderboard

    -
    - {data.totalParticipants} {data.totalParticipants === 1 ? "participant" : "participants"} -
    - -
    - - - - - - - - - - - {data.entries.map((entry) => { - const isUser = isConnected && userAddr === entry.address.toLowerCase(); - return ( - - - - - - - ); - })} - -
    #UserPLShare
    {entry.rank} - {entry.username ?? truncateAddress(entry.address)} - {isUser && (you)} - - {entry.totalPoints.toLocaleString()} - - {entry.sharePercent}% -
    -
    - - {/* Pagination */} - {data.totalPages > 1 && ( -
    - - - {data.page}/{data.totalPages} - - -
    - )} - - {/* User's rank if outside current page */} - {isConnected && !onCurrentPage && data.userRank && ( -
    - Your rank: - #{data.userRank} -
    - )} -
    - ); -} diff --git a/src/components/airdrop/MilestoneClimb.tsx b/src/components/airdrop/MilestoneClimb.tsx deleted file mode 100644 index 26578267..00000000 --- a/src/components/airdrop/MilestoneClimb.tsx +++ /dev/null @@ -1,168 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; - -interface StatusData { - currentFdv: number; - latestPriceUsd: number | null; - poolAmount: number; - milestones: { - bronze: { mcap: number; pct: number; reached: boolean }; - silver: { mcap: number; pct: number; reached: boolean }; - gold: { mcap: number; pct: number; reached: boolean }; - diamond: { mcap: number; pct: number; reached: boolean }; - }; -} - -interface MilestoneClimbProps { - dimmed?: boolean; -} - -const TIERS = [ - { key: "bronze", emoji: "\u{1F949}", label: "Bronze", cx: 140, cy: 164 }, - { key: "silver", emoji: "\u{1F948}", label: "Silver", cx: 260, cy: 125 }, - { key: "gold", emoji: "\u{1F947}", label: "Gold", cx: 400, cy: 55 }, - { key: "diamond", emoji: "\u{1F48E}", label: "Diamond", cx: 530, cy: 18 }, -] as const; - -function formatMcap(n: number): string { - if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}K`; - return `$${n}`; -} - -function formatPool(n: number): string { - if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}K`; - if (n >= 1) return `$${Math.round(n)}`; - return "$0"; -} - -function interpolateFdvX(fdv: number, milestones: StatusData["milestones"]): number { - const mcaps = [milestones.bronze.mcap, milestones.silver.mcap, milestones.gold.mcap, milestones.diamond.mcap]; - const xs = [140, 260, 400, 530]; - - if (fdv <= 0) return 30; - if (fdv >= mcaps[3]) return 530; - - const logFdv = Math.log10(fdv); - const logMin = Math.log10(mcaps[0] * 0.1); - - for (let i = 0; i < mcaps.length; i++) { - if (fdv <= mcaps[i]) { - const prevX = i === 0 ? 30 : xs[i - 1]; - const prevLog = i === 0 ? logMin : Math.log10(mcaps[i - 1]); - const curLog = Math.log10(mcaps[i]); - const t = (logFdv - prevLog) / (curLog - prevLog); - return prevX + t * (xs[i] - prevX); - } - } - return 530; -} - -function interpolateFdvY(x: number): number { - const points = [[30, 175], [140, 164], [260, 125], [400, 55], [530, 18]]; - for (let i = 1; i < points.length; i++) { - if (x <= points[i][0]) { - const t = (x - points[i - 1][0]) / (points[i][0] - points[i - 1][0]); - return points[i - 1][1] + t * (points[i][1] - points[i - 1][1]); - } - } - return 18; -} - -export function MilestoneClimb({ dimmed }: MilestoneClimbProps) { - const [status, setStatus] = useState(null); - - useEffect(() => { - fetch("/api/airdrop/status") - .then(r => r.ok ? r.json() : null) - .then(d => { if (d) setStatus(d); }) - .catch(() => {}); - }, []); - - if (!status) { - return ( -
    -

    Loading milestone chart...

    -
    - ); - } - - const { milestones, currentFdv, latestPriceUsd, poolAmount } = status; - const plotPrice = latestPriceUsd ?? 0; - - const tierData = TIERS.map(t => { - const m = milestones[t.key as keyof typeof milestones]; - return { - ...t, - mcap: m.mcap, - pct: m.pct, - reached: m.reached, - poolUsd: (poolAmount * m.pct / 100) * plotPrice, - }; - }); - - const fdvX = interpolateFdvX(currentFdv, milestones); - const fdvY = interpolateFdvY(fdvX); - - return ( -
    -
    - - - - - - - - - - - - - - - - {tierData.map(t => ( - - - - {t.emoji} - - ))} - - - - - - - -
    - TODAY {formatMcap(currentFdv)} -
    -
    - -
    - {tierData.map(t => ( -
    -
    {t.emoji} {t.label}
    -
    {formatMcap(t.mcap)}
    -
    → ~{formatPool(t.poolUsd)} pool
    -
    - ))} -
    -
    - ); -} diff --git a/src/components/airdrop/ReferralCTA.tsx b/src/components/airdrop/ReferralCTA.tsx deleted file mode 100644 index d90ab9e2..00000000 --- a/src/components/airdrop/ReferralCTA.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useAccount } from "wagmi"; - -export function ReferralCTA() { - const { address, isConnected } = useAccount(); - const [fetchState, setFetchState] = useState<{ code: string | null; addr: string | null }>({ code: null, addr: null }); - const [copied, setCopied] = useState(false); - - useEffect(() => { - if (!isConnected || !address) return; - - let cancelled = false; - fetch(`/api/airdrop/referral-code?address=${address.toLowerCase()}`) - .then(r => r.ok ? r.json() : null) - .then(d => { if (!cancelled) setFetchState({ code: d?.code ?? null, addr: address.toLowerCase() }); }) - .catch(() => { if (!cancelled) setFetchState({ code: null, addr: address.toLowerCase() }); }); - return () => { cancelled = true; }; - }, [isConnected, address]); - - const code = fetchState.addr === address?.toLowerCase() ? fetchState.code : null; - - if (!code) { - return ( -
    -

    Invite Friends

    -

    Your referral link will appear once activation is complete.

    -
    - ); - } - - const refUrl = `https://plotlink.xyz/?ref=${code}`; - const shareText = `Join the PlotLink Buy-Back Sprint! Use my referral link to boost both our multipliers:`; - const xShareUrl = `https://x.com/intent/post?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(refUrl)}`; - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(refUrl); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - const input = document.createElement("input"); - input.value = refUrl; - document.body.appendChild(input); - input.select(); - document.execCommand("copy"); - document.body.removeChild(input); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; - - return ( -
    -

    Invite Friends

    - -
    - {refUrl} - -
    - - - Share on X - - -

    - Each qualified referral adds +0.2 to your multiplier (up to 3.0×). -

    -
    - ); -} diff --git a/src/components/airdrop/StorylineSprintBanner.tsx b/src/components/airdrop/StorylineSprintBanner.tsx deleted file mode 100644 index 18d36edb..00000000 --- a/src/components/airdrop/StorylineSprintBanner.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import Link from "next/link"; - -const DISMISS_KEY = "plotlink_sprint_banner_dismissed"; - -function formatMcap(n: number): string { - if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}K`; - return `$${n}`; -} - -export function StorylineSprintBanner() { - const [state, setState] = useState<{ - dismissed: boolean; - status: { timeElapsedPercent: number; currentFdv: number } | null; - }>({ dismissed: true, status: null }); - - useEffect(() => { - const isDismissed = localStorage.getItem(DISMISS_KEY) === "1"; - fetch("/api/airdrop/status") - .then(r => r.ok ? r.json() : null) - .then(d => setState({ - dismissed: isDismissed, - status: d ? { timeElapsedPercent: d.timeElapsedPercent, currentFdv: d.currentFdv } : null, - })) - .catch(() => setState(prev => ({ ...prev, dismissed: isDismissed }))); - }, []); - - const { dismissed, status } = state; - - if (dismissed) return null; - - const handleDismiss = () => { - localStorage.setItem(DISMISS_KEY, "1"); - setState(prev => ({ ...prev, dismissed: true })); - }; - - const dayNum = status ? Math.ceil(status.timeElapsedPercent / 100 * 90) || 1 : null; - - return ( -
    -
    - Buy-Back Sprint - {dayNum && status && ( - · Day {dayNum}/90 · FDV {formatMcap(status.currentFdv)} - )} -
    - - Join - - -
    - ); -} diff --git a/src/components/airdrop/UserPoints.tsx b/src/components/airdrop/UserPoints.tsx deleted file mode 100644 index fb6caa3e..00000000 --- a/src/components/airdrop/UserPoints.tsx +++ /dev/null @@ -1,414 +0,0 @@ -"use client"; - -import { useState, useMemo, useRef, useEffect } from "react"; -import { useAccount, useSignMessage } from "wagmi"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useConnectedIdentity } from "../../hooks/useConnectedIdentity"; -import { formatUsdValue } from "../../../lib/usd-price"; -import { REFERRAL_STORAGE_KEY } from "../../hooks/useReferralCapture"; - -interface PointsData { - address: string; - totalPoints: number; - sharePercent: number; - breakdown: { buy: number; referral: number; write: number; rate: number }; - streak: { - currentStreak: number; - boostPercent: number; - nextTier: { days: number; boost: number } | null; - checkedInToday: boolean; - lastCheckin: string | null; - }; - referral: { - code: string | null; - isFarcasterUsername: boolean; - referredBy: string | null; - referredUsersCount: number; - }; - estimatedAirdrop: { bronze: number; silver: number; gold: number; diamond: number }; -} - -interface StatusData { - latestPriceUsd: number | null; - milestones: { - bronze: { mcap: number; pct: number; reached: boolean }; - silver: { mcap: number; pct: number; reached: boolean }; - gold: { mcap: number; pct: number; reached: boolean }; - diamond: { mcap: number; pct: number; reached: boolean }; - }; - currentFdv: number; -} - -const ACTIONS: { key: keyof PointsData["breakdown"]; label: string }[] = [ - { key: "buy", label: "Buying" }, - { key: "referral", label: "Referrals" }, - { key: "write", label: "Writing" }, - { key: "rate", label: "Rating" }, -]; - -const MAX_SUPPLY = 1_000_000; -const TIER_KEYS = ["bronze", "silver", "gold", "diamond"] as const; - -function useAirdropPoints(address: string | undefined) { - return useQuery({ - queryKey: ["airdrop-points", address], - queryFn: async () => { - const res = await fetch(`/api/airdrop/points?address=${address!.toLowerCase()}`); - if (!res.ok) throw new Error("Failed to fetch points"); - return res.json(); - }, - enabled: !!address, - staleTime: 60_000, - refetchInterval: 60_000, - }); -} - -function formatCompact(val: number): string { - if (val >= 1_000_000) return `$${(val / 1_000_000).toFixed(0)}M`; - if (val >= 1_000) return `$${(val / 1_000).toFixed(0)}K`; - return `$${val.toFixed(0)}`; -} - -/* ─── Tooltip component ─── */ - -function InfoTooltip({ children }: { children: React.ReactNode }) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - if (!open) return; - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - } - document.addEventListener("click", handleClick, true); - return () => document.removeEventListener("click", handleClick, true); - }, [open]); - - return ( -
    - - {open && ( -
    - {children} -
    - )} -
    - ); -} - -export function UserPoints() { - const { address, isConnected } = useAccount(); - - if (!isConnected || !address) { - return ( -
    -

    Connect your wallet to view your points.

    -
    - ); - } - - return ; -} - -function UserPointsInner({ address }: { address: string }) { - const { data, isLoading } = useAirdropPoints(address); - const { profile: farcasterProfile } = useConnectedIdentity(); - const [breakdownOpen, setBreakdownOpen] = useState(false); - - const { data: statusData } = useQuery({ - queryKey: ["airdrop-status"], - queryFn: async () => { - const res = await fetch("/api/airdrop/status"); - if (!res.ok) throw new Error("Failed to fetch status"); - return res.json(); - }, - staleTime: 60_000, - }); - - const currentEstimate = useMemo(() => { - if (!data || !statusData) return null; - const currentFdv = statusData.currentFdv; - const price = statusData.latestPriceUsd; - let key: (typeof TIER_KEYS)[number] = "bronze"; - for (let i = TIER_KEYS.length - 1; i >= 0; i--) { - if (currentFdv >= statusData.milestones[TIER_KEYS[i]].mcap) { - key = TIER_KEYS[i]; - break; - } - } - const amount = data.estimatedAirdrop[key]; - return { amount, usd: price && amount > 0 ? amount * price : null }; - }, [data, statusData]); - - if (isLoading || !data) { - return ( -
    -
    Loading your points...
    -
    - ); - } - - return ( -
    - {/* Points summary */} -
    -
    -
    - Your PL Points -
    {data.totalPoints.toLocaleString()}
    -
    -
    -
    Share
    -
    {data.sharePercent.toFixed(2)}%
    -
    -
    - - {/* Single estimated airdrop line + tooltip */} - {currentEstimate && ( -
    - Est. airdrop: - - {currentEstimate.usd ? `~${formatUsdValue(currentEstimate.usd)}` : `${currentEstimate.amount.toLocaleString()} PLOT`} - - {currentEstimate.usd && ({currentEstimate.amount.toLocaleString()} PLOT)} - -
    - {TIER_KEYS.map((key) => { - const amount = data.estimatedAirdrop[key]; - const fdv = statusData?.milestones[key].mcap ?? 0; - const scenarioPrice = fdv / MAX_SUPPLY; - const scenarioUsd = amount > 0 ? formatUsdValue(amount * scenarioPrice) : null; - return ( -
    - At {formatCompact(fdv)} MCap →{" "} - - {scenarioUsd ? `~${scenarioUsd}` : `${amount.toLocaleString()} PLOT`} - - {scenarioUsd && ({amount.toLocaleString()} PLOT)} -
    - ); - })} -
    -
    -
    (based on current MCap)
    -
    - )} -
    - - {/* Streak card */} - - {/* Point breakdown — collapsed by default */} -
    - - {breakdownOpen && ( -
    - {ACTIONS.map(({ key, label }) => { - const pts = data.breakdown[key]; - const pct = data.totalPoints > 0 ? Math.round((pts / data.totalPoints) * 100) : 0; - return ( -
    - {label} - - {pts.toLocaleString()} PL - ({pct}%) - {data.streak.boostPercent > 0 && pts > 0 && ( - +{data.streak.boostPercent}% - )} - -
    - ); - })} -
    - )} -
    - - {/* Referral section */} - -
    - ); -} - -function ReferralSection({ - referral, - address, - hasFarcaster, -}: { - referral: PointsData["referral"]; - address: string; - hasFarcaster: boolean; -}) { - const { signMessageAsync } = useSignMessage(); - const queryClient = useQueryClient(); - const [referrerCode, setReferrerCode] = useState(() => { - if (typeof window !== "undefined") { - return localStorage.getItem(REFERRAL_STORAGE_KEY) ?? ""; - } - return ""; - }); - const [error, setError] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [copied, setCopied] = useState(false); - - const origin = typeof window !== "undefined" ? window.location.origin : ""; - const referralLink = referral.code ? `${origin}/airdrop?ref=${referral.code}` : null; - - const handleCopy = async () => { - if (!referralLink) return; - await navigator.clipboard.writeText(referralLink); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const handleShare = () => { - if (!referralLink) return; - const text = `Earn PL points in the PLOT Big or Nothing Airdrop! ${referralLink}`; - window.open( - `https://x.com/intent/tweet?text=${encodeURIComponent(text)}`, - "_blank", - ); - }; - - const handleSetReferrer = async () => { - if (!referrerCode.trim()) return; - setError(""); - setSubmitting(true); - try { - const message = `${address}\n\nRegister referral code: ${referrerCode.trim()}\nTimestamp: ${new Date().toISOString()}`; - const signature = await signMessageAsync({ message }); - - const res = await fetch("/api/airdrop/register-referral", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message, signature, referralCode: referrerCode.trim() }), - }); - - const data = await res.json(); - if (!res.ok) { - setError(data.error ?? "Registration failed"); - return; - } - - localStorage.removeItem(REFERRAL_STORAGE_KEY); - queryClient.invalidateQueries({ queryKey: ["airdrop-points", address] }); - } catch { - setError("Signature rejected or failed"); - } finally { - setSubmitting(false); - } - }; - - const handleUseFarcaster = async () => { - setError(""); - setSubmitting(true); - try { - const message = `${address}\n\nGenerate referral code with Farcaster username\nTimestamp: ${new Date().toISOString()}`; - const signature = await signMessageAsync({ message }); - - const res = await fetch("/api/airdrop/referral-code", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message, signature, useFarcasterUsername: true }), - }); - - if (!res.ok) { - const data = await res.json(); - setError(data.error ?? "Failed to generate code"); - return; - } - - queryClient.invalidateQueries({ queryKey: ["airdrop-points", address] }); - } catch { - setError("Signature rejected or failed"); - } finally { - setSubmitting(false); - } - }; - - return ( -
    - {/* Referral link + actions */} - {referralLink ? ( -
    -
    - 🔗 - - {referralLink} - - - -
    -
    - {referral.referredUsersCount} referred - {referral.referredBy && ( - - {" · "}Referred by: {referral.referredBy} - - )} -
    -
    - ) : ( -
    - {hasFarcaster && ( - - )} -
    - )} - - {/* Referred by input (only if not yet set) */} - {!referral.referredBy && ( -
    - setReferrerCode(e.target.value)} - placeholder="Who referred you?" - className="bg-surface border-border text-foreground placeholder:text-muted flex-1 rounded border px-2 py-1 text-xs font-mono focus:border-accent focus:outline-none" - /> - -
    - )} - - {error &&
    {error}
    } -
    - ); -} diff --git a/src/hooks/useReferralCapture.ts b/src/hooks/useReferralCapture.ts deleted file mode 100644 index 09249b92..00000000 --- a/src/hooks/useReferralCapture.ts +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useSearchParams } from "next/navigation"; - -export const REFERRAL_STORAGE_KEY = "plotlink_ref"; - -/** - * Global hook that captures ?ref= query param into localStorage. - * The actual registration happens via SIWE on the /airdrop page - * (manual "Who referred you?" flow or pre-filled from stored code). - */ -export function useReferralCapture() { - const searchParams = useSearchParams(); - - useEffect(() => { - const ref = searchParams.get("ref"); - if (ref) { - // Only store if user doesn't already have a referrer stored - if (!localStorage.getItem(REFERRAL_STORAGE_KEY)) { - localStorage.setItem(REFERRAL_STORAGE_KEY, ref); - } - } - }, [searchParams]); -} diff --git a/src/hooks/useReferralCode.ts b/src/hooks/useReferralCode.ts deleted file mode 100644 index 85bed32b..00000000 --- a/src/hooks/useReferralCode.ts +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { useAccount } from "wagmi"; - -/** - * Fetches the connected user's referral code (if they have one). - * Returns null if no code generated yet or wallet not connected. - */ -export function useReferralCode() { - const { address, isConnected } = useAccount(); - - return useQuery({ - queryKey: ["referral-code", address], - queryFn: async () => { - const res = await fetch( - `/api/airdrop/referral-code?address=${address!.toLowerCase()}`, - ); - if (!res.ok) return null; - const data = await res.json(); - return (data.code as string) ?? null; - }, - enabled: isConnected && !!address, - staleTime: Infinity, // code is immutable - }); -} diff --git a/supabase/migrations/00042_airdrop_teardown.sql b/supabase/migrations/00042_airdrop_teardown.sql new file mode 100644 index 00000000..0767989e --- /dev/null +++ b/supabase/migrations/00042_airdrop_teardown.sql @@ -0,0 +1,10 @@ +-- Airdrop teardown (campaign paused). Data archived separately before apply. +DROP FUNCTION IF EXISTS weighted_spend(TIMESTAMPTZ, TIMESTAMPTZ, NUMERIC, NUMERIC, NUMERIC); +DROP TABLE IF EXISTS pl_airdrop_proofs; +DROP TABLE IF EXISTS pl_weekly_snapshots; +DROP TABLE IF EXISTS pl_daily_prices; +DROP TABLE IF EXISTS pl_referrals; +DROP TABLE IF EXISTS pl_referral_codes; +DROP TABLE IF EXISTS pl_streaks; +DROP TABLE IF EXISTS pl_points; +DROP TABLE IF EXISTS pl_activations; diff --git a/vercel.json b/vercel.json index 45408387..c9963fd9 100644 --- a/vercel.json +++ b/vercel.json @@ -3,14 +3,6 @@ { "path": "/api/cron/backfill", "schedule": "*/5 * * * *" - }, - { - "path": "/api/cron/airdrop-price", - "schedule": "0 0 * * *" - }, - { - "path": "/api/cron/airdrop-weekly", - "schedule": "0 0 * * 1" } ] }