From e09cdb7280913e0e4bc018a9a1445bba7eef7779 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 21 Apr 2026 09:13:24 +0900 Subject: [PATCH 1/3] [#882] Add streak check-in system with SIWE verification - New POST /api/airdrop/checkin endpoint with SIWE signature verification - New lib/airdrop/streak.ts with getStreakBoost, dropOneTier, getNextTier - Move getStreakBoost from points.ts to streak.ts (re-exported for compat) - One check-in per calendar day (UTC), 30-min minimum gap - Consecutive days increment streak, missed days drop one tier (not reset) - Returns streak count, boost %, next tier info, checkedInToday flag Fixes #882 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/airdrop/points.ts | 18 +--- lib/airdrop/streak.ts | 63 +++++++++++ src/app/api/airdrop/checkin/route.ts | 154 +++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 16 deletions(-) create mode 100644 lib/airdrop/streak.ts create mode 100644 src/app/api/airdrop/checkin/route.ts diff --git a/lib/airdrop/points.ts b/lib/airdrop/points.ts index 1f08ae8f..881bfc06 100644 --- a/lib/airdrop/points.ts +++ b/lib/airdrop/points.ts @@ -5,23 +5,9 @@ */ import { AIRDROP_CONFIG } from "./config"; +import { getStreakBoost } from "./streak"; -/** - * 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 { - const thresholds = Object.keys(AIRDROP_CONFIG.STREAK_BOOSTS) - .map(Number) - .sort((a, b) => b - a); // descending - - for (const threshold of thresholds) { - if (currentStreak >= threshold) { - return AIRDROP_CONFIG.STREAK_BOOSTS[threshold]; - } - } - return 0; -} +export { getStreakBoost }; /** * Compute buy points for a trade. diff --git a/lib/airdrop/streak.ts b/lib/airdrop/streak.ts new file mode 100644 index 00000000..9e036adc --- /dev/null +++ b/lib/airdrop/streak.ts @@ -0,0 +1,63 @@ +/** + * 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. + * E.g. streak 45 (in 30-49 range) → drops to 30. + * streak 120 → drops to 100. + * streak 5 → drops to 0. + */ +export function dropOneTier(streak: number): number { + // Find the current tier threshold + for (let i = TIER_THRESHOLDS.length - 1; i >= 0; i--) { + if (streak >= TIER_THRESHOLDS[i]) { + // If at or above this tier, drop to this tier's threshold + // But if already exactly at the threshold, drop to the tier below + if (streak > TIER_THRESHOLDS[i]) { + return TIER_THRESHOLDS[i]; + } + // Exactly at the threshold — drop to previous tier + 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/src/app/api/airdrop/checkin/route.ts b/src/app/api/airdrop/checkin/route.ts new file mode 100644 index 00000000..c087597f --- /dev/null +++ b/src/app/api/airdrop/checkin/route.ts @@ -0,0 +1,154 @@ +/** + * Streak check-in endpoint (#882) + * + * POST /api/airdrop/checkin + * Body: { message: string, signature: string } + * + * Verifies SIWE signature, updates streak in pl_streaks. + */ + +import { NextResponse } from "next/server"; +import { verifyMessage } from "viem"; +import { createServerClient } from "../../../../../lib/supabase"; +import { AIRDROP_CONFIG } from "../../../../../lib/airdrop/config"; +import { getStreakBoost, dropOneTier, getNextTier } from "../../../../../lib/airdrop/streak"; + +export async function POST(req: Request) { + const supabase = createServerClient(); + if (!supabase) { + return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); + } + + let message: string; + let signature: `0x${string}`; + try { + const body = await req.json(); + message = body.message; + signature = body.signature; + if (!message || !signature) throw new Error("missing fields"); + } catch { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + + // Parse SIWE message to extract address + 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 NextResponse.json({ error: "Invalid SIWE message" }, { status: 400 }); + } + const claimedAddress = addressMatch[1].toLowerCase(); + + // Verify signature + let valid: boolean; + try { + valid = await verifyMessage({ + address: claimedAddress as `0x${string}`, + message, + signature, + }); + } catch { + valid = false; + } + if (!valid) { + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + } + + const now = new Date(); + + // Campaign window check + if (now < AIRDROP_CONFIG.CAMPAIGN_START || now > AIRDROP_CONFIG.CAMPAIGN_END) { + return NextResponse.json({ error: "Campaign not active" }, { status: 400 }); + } + + // Fetch or create streak record + const { data: existing } = await supabase + .from("pl_streaks") + .select("*") + .eq("address", claimedAddress) + .single(); + + const todayUtc = now.toISOString().slice(0, 10); // YYYY-MM-DD + + if (existing?.last_checkin) { + const lastCheckin = new Date(existing.last_checkin); + const lastCheckinDay = lastCheckin.toISOString().slice(0, 10); + + // Reject if same calendar day (UTC) + if (lastCheckinDay === todayUtc) { + return NextResponse.json({ + error: "Already checked in today", + streak: existing.current_streak, + boostPercent: getStreakBoost(existing.current_streak) * 100, + nextTier: getNextTier(existing.current_streak), + checkedInToday: true, + }, { status: 429 }); + } + + // Reject if less than 30 minutes ago + const minutesSince = (now.getTime() - lastCheckin.getTime()) / (1000 * 60); + if (minutesSince < AIRDROP_CONFIG.STREAK_MIN_GAP_MINUTES) { + return NextResponse.json({ + error: `Must wait ${AIRDROP_CONFIG.STREAK_MIN_GAP_MINUTES} minutes between check-ins`, + }, { status: 429 }); + } + } + + let newStreak: number; + + if (!existing) { + // First ever check-in + newStreak = 1; + const { error } = await supabase.from("pl_streaks").insert({ + address: claimedAddress, + current_streak: newStreak, + last_checkin: now.toISOString(), + longest_streak: newStreak, + }); + if (error) { + console.error("[checkin] Insert failed:", error.message); + return NextResponse.json({ error: "Check-in failed" }, { status: 500 }); + } + } else { + const lastCheckin = existing.last_checkin ? new Date(existing.last_checkin) : null; + + if (lastCheckin) { + const lastDay = lastCheckin.toISOString().slice(0, 10); + const yesterdayUtc = new Date(now.getTime() - 86400000).toISOString().slice(0, 10); + + if (lastDay === yesterdayUtc) { + // Consecutive day — increment streak + newStreak = existing.current_streak + 1; + } else { + // Missed 2+ days — drop one tier + newStreak = dropOneTier(existing.current_streak); + // Then add today's check-in + newStreak = Math.max(newStreak, 1); + } + } else { + newStreak = 1; + } + + const longestStreak = Math.max(existing.longest_streak, newStreak); + + const { error } = await supabase + .from("pl_streaks") + .update({ + current_streak: newStreak, + last_checkin: now.toISOString(), + longest_streak: longestStreak, + }) + .eq("address", claimedAddress); + + if (error) { + console.error("[checkin] Update failed:", error.message); + return NextResponse.json({ error: "Check-in failed" }, { status: 500 }); + } + } + + return NextResponse.json({ + streak: newStreak, + boostPercent: getStreakBoost(newStreak) * 100, + nextTier: getNextTier(newStreak), + checkedInToday: true, + }); +} From a5237116dd6fc9bdeee14fd365a0b5599c6d48f5 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 21 Apr 2026 09:15:53 +0900 Subject: [PATCH 2/3] [#882] Fix dropOneTier to snap to current tier threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per spec: 100+ → 100, 50-99 → 50, ..., 1-6 → 0. dropOneTier now snaps to the current tier's threshold (not below it). Check-in route adds +1 for today's check-in after the drop. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/airdrop/streak.ts | 15 ++++----------- src/app/api/airdrop/checkin/route.ts | 6 ++---- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/lib/airdrop/streak.ts b/lib/airdrop/streak.ts index 9e036adc..87202c84 100644 --- a/lib/airdrop/streak.ts +++ b/lib/airdrop/streak.ts @@ -25,21 +25,14 @@ export function getStreakBoost(currentStreak: number): number { /** * Drop one tier after a missed day. Returns the new streak value. - * E.g. streak 45 (in 30-49 range) → drops to 30. - * streak 120 → drops to 100. - * streak 5 → drops to 0. + * Per spec: streak snaps down to the current tier's threshold. + * 100+ → 100, 50-99 → 50, 30-49 → 30, 14-29 → 14, 7-13 → 7, 1-6 → 0 */ export function dropOneTier(streak: number): number { - // Find the current tier threshold + // Find the current tier and snap to its threshold for (let i = TIER_THRESHOLDS.length - 1; i >= 0; i--) { if (streak >= TIER_THRESHOLDS[i]) { - // If at or above this tier, drop to this tier's threshold - // But if already exactly at the threshold, drop to the tier below - if (streak > TIER_THRESHOLDS[i]) { - return TIER_THRESHOLDS[i]; - } - // Exactly at the threshold — drop to previous tier - return i > 0 ? TIER_THRESHOLDS[i - 1] : 0; + return TIER_THRESHOLDS[i]; } } return 0; diff --git a/src/app/api/airdrop/checkin/route.ts b/src/app/api/airdrop/checkin/route.ts index c087597f..91ae8bca 100644 --- a/src/app/api/airdrop/checkin/route.ts +++ b/src/app/api/airdrop/checkin/route.ts @@ -119,10 +119,8 @@ export async function POST(req: Request) { // Consecutive day — increment streak newStreak = existing.current_streak + 1; } else { - // Missed 2+ days — drop one tier - newStreak = dropOneTier(existing.current_streak); - // Then add today's check-in - newStreak = Math.max(newStreak, 1); + // Missed 2+ days — drop one tier, then add today's check-in + newStreak = dropOneTier(existing.current_streak) + 1; } } else { newStreak = 1; From 0330a43cd2220c27cb781fcc7ddda00b1373ebb2 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 21 Apr 2026 09:16:55 +0900 Subject: [PATCH 3/3] [#882] Remove +1 after missed-day tier drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per spec, missed days set streak to the tier threshold exactly (e.g. 1-6 → 0, 50-99 → 50). The check-in itself does not increment the dropped value. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/airdrop/checkin/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/airdrop/checkin/route.ts b/src/app/api/airdrop/checkin/route.ts index 91ae8bca..1dc70d09 100644 --- a/src/app/api/airdrop/checkin/route.ts +++ b/src/app/api/airdrop/checkin/route.ts @@ -119,8 +119,8 @@ export async function POST(req: Request) { // Consecutive day — increment streak newStreak = existing.current_streak + 1; } else { - // Missed 2+ days — drop one tier, then add today's check-in - newStreak = dropOneTier(existing.current_streak) + 1; + // Missed 2+ days — snap to current tier threshold + newStreak = dropOneTier(existing.current_streak); } } else { newStreak = 1;