From 6cf52ed44977ff7469f681c4be44ba5e82400c03 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 21 Apr 2026 08:58:38 +0900 Subject: [PATCH 1/2] [#881] Add buy points sync cron and points helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New POST /api/cron/airdrop-points endpoint (every 5 min) - Syncs trade_history mint events → pl_points with streak boost - Awards referral points (20%) to referrers with their own streak boost - Filters out ZAP_PLOTLINK self-mints - Idempotent via metadata.trade_id dedup - Respects campaign window (no points after CAMPAIGN_END) - New lib/airdrop/points.ts with computeBuyPoints/computeReferralPoints Fixes #881 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/airdrop/points.ts | 53 +++++++ src/app/api/cron/airdrop-points/route.ts | 173 +++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 lib/airdrop/points.ts create mode 100644 src/app/api/cron/airdrop-points/route.ts diff --git a/lib/airdrop/points.ts b/lib/airdrop/points.ts new file mode 100644 index 00000000..f959da7a --- /dev/null +++ b/lib/airdrop/points.ts @@ -0,0 +1,53 @@ +/** + * Airdrop points helpers (#881) + * + * Computes buy points with streak boost, and referral points. + */ + +import { AIRDROP_CONFIG } from "./config"; + +/** + * 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; +} + +/** + * Compute buy points for a trade. + * Points = PLOT spent × BUY_PER_PLOT × (1 + streak boost) + */ +export function computeBuyPoints( + plotSpent: number, + currentStreak: number, +): number { + const base = plotSpent * AIRDROP_CONFIG.POINTS.BUY_PER_PLOT; + const boost = getStreakBoost(currentStreak); + return base * (1 + boost); +} + +/** + * Compute referral points (percentage of buyer's buy points). + * Also boosted by the referrer's own streak. + */ +export function computeReferralPoints( + buyerPlotSpent: number, + referrerStreak: number, +): number { + const base = + buyerPlotSpent * + AIRDROP_CONFIG.POINTS.BUY_PER_PLOT * + (AIRDROP_CONFIG.POINTS.REFERRAL_PCT / 100); + const boost = getStreakBoost(referrerStreak); + return base * (1 + boost); +} diff --git a/src/app/api/cron/airdrop-points/route.ts b/src/app/api/cron/airdrop-points/route.ts new file mode 100644 index 00000000..aba1a152 --- /dev/null +++ b/src/app/api/cron/airdrop-points/route.ts @@ -0,0 +1,173 @@ +/** + * Airdrop buy-points sync cron (#881) + * + * Syncs trade_history mint events → pl_points for buy + referral points. + * Schedule: every 5 min + */ + +import { NextResponse } from "next/server"; +import { createServerClient } from "../../../../../lib/supabase"; +import { ZAP_PLOTLINK } from "../../../../../lib/contracts/constants"; +import { AIRDROP_CONFIG } from "../../../../../lib/airdrop/config"; +import { computeBuyPoints, computeReferralPoints } from "../../../../../lib/airdrop/points"; + +/** Fail closed in production when CRON_SECRET is unset */ +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 POST(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 now = new Date(); + if (now > AIRDROP_CONFIG.CAMPAIGN_END) { + return NextResponse.json({ message: "Campaign ended, no points awarded" }); + } + + const zapAddress = ZAP_PLOTLINK.toLowerCase(); + + // Fetch mint trades within the campaign window + 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", AIRDROP_CONFIG.CAMPAIGN_START.toISOString()) + .lte("block_timestamp", AIRDROP_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 }); + } + + // Filter out ZAP_PLOTLINK self-mints + const eligible = trades.filter( + (t) => t.user_address && t.user_address.toLowerCase() !== zapAddress, + ); + + // Fetch existing trade_ids from pl_points to dedup + 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), + ); + + // Collect unique buyer addresses for streak lookup + const buyerAddresses = [ + ...new Set(eligible.filter((t) => !processedTradeIds.has(String(t.id))).map((t) => t.user_address!.toLowerCase())), + ]; + + // Batch-fetch streaks + const { data: streaks } = await supabase + .from("pl_streaks") + .select("address, current_streak") + .in("address", buyerAddresses); + + const streakMap = new Map( + (streaks ?? []).map((s) => [s.address.toLowerCase(), s.current_streak]), + ); + + // Batch-fetch referrals for buyers + const { data: referrals } = await supabase + .from("pl_referrals") + .select("referred_address, referrer_address") + .in("referred_address", buyerAddresses); + + const referralMap = new Map( + (referrals ?? []).map((r) => [r.referred_address.toLowerCase(), r.referrer_address.toLowerCase()]), + ); + + // Collect referrer addresses for streak lookup + const referrerAddresses = [...new Set(referralMap.values())]; + const { data: referrerStreaks } = referrerAddresses.length > 0 + ? await supabase + .from("pl_streaks") + .select("address, current_streak") + .in("address", referrerAddresses) + : { data: [] }; + + const referrerStreakMap = new Map( + (referrerStreaks ?? []).map((s) => [s.address.toLowerCase(), s.current_streak]), + ); + + let buyCount = 0; + let referralCount = 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 plotSpent = trade.reserve_amount; + const buyerStreak = streakMap.get(address) ?? 0; + + // Buy points + const buyPoints = computeBuyPoints(plotSpent, buyerStreak); + inserts.push({ + address, + action: "buy", + points: buyPoints, + metadata: { trade_id: trade.id }, + }); + buyCount++; + + // Referral points + const referrer = referralMap.get(address); + if (referrer) { + const referrerStreak = referrerStreakMap.get(referrer) ?? 0; + const refPoints = computeReferralPoints(plotSpent, referrerStreak); + inserts.push({ + address: referrer, + action: "referral", + points: refPoints, + metadata: { trade_id: trade.id, referred_address: address }, + }); + referralCount++; + } + } + + 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, ${referralCount} referrals`); + return NextResponse.json({ + message: "Points synced", + processed: { buys: buyCount, referrals: referralCount }, + }); +} From b43d2c3f223eaca5e1ead04b65bcc47d903c123b Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 21 Apr 2026 09:00:22 +0900 Subject: [PATCH 2/2] [#881] Fix referral points to use buyer's boosted buy points computeReferralPoints now takes the buyer's streak-boosted buyPoints instead of raw plotSpent, so referral awards correctly reflect 20% of the buyer's actual earned points. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/airdrop/points.ts | 9 +++------ src/app/api/cron/airdrop-points/route.ts | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/airdrop/points.ts b/lib/airdrop/points.ts index f959da7a..1f08ae8f 100644 --- a/lib/airdrop/points.ts +++ b/lib/airdrop/points.ts @@ -37,17 +37,14 @@ export function computeBuyPoints( } /** - * Compute referral points (percentage of buyer's buy points). + * Compute referral points (percentage of buyer's boosted buy points). * Also boosted by the referrer's own streak. */ export function computeReferralPoints( - buyerPlotSpent: number, + buyerBoostedPoints: number, referrerStreak: number, ): number { - const base = - buyerPlotSpent * - AIRDROP_CONFIG.POINTS.BUY_PER_PLOT * - (AIRDROP_CONFIG.POINTS.REFERRAL_PCT / 100); + const base = buyerBoostedPoints * (AIRDROP_CONFIG.POINTS.REFERRAL_PCT / 100); const boost = getStreakBoost(referrerStreak); return base * (1 + boost); } diff --git a/src/app/api/cron/airdrop-points/route.ts b/src/app/api/cron/airdrop-points/route.ts index aba1a152..f28775ce 100644 --- a/src/app/api/cron/airdrop-points/route.ts +++ b/src/app/api/cron/airdrop-points/route.ts @@ -146,7 +146,7 @@ export async function POST(req: Request) { const referrer = referralMap.get(address); if (referrer) { const referrerStreak = referrerStreakMap.get(referrer) ?? 0; - const refPoints = computeReferralPoints(plotSpent, referrerStreak); + const refPoints = computeReferralPoints(buyPoints, referrerStreak); inserts.push({ address: referrer, action: "referral",