Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 7 additions & 108 deletions lib/airdrop/award.ts
Original file line number Diff line number Diff line change
@@ -1,115 +1,14 @@
/**
* Airdrop point award helpers (#884)
*
* Convenience functions for awarding write and rate points,
* called from indexer/backfill/rating endpoints.
*/

import { AIRDROP_CONFIG } from "./config";
import { getStreakBoost } from "./streak";
import { createServiceRoleClient } from "../supabase";

/**
* Award write points (50 PL) for publishing a storyline.
* Idempotent via metadata.storyline_id dedup.
*/
export async function awardWritePoints(
writerAddress: string,
storylineId: number,
timestamp?: Date,
_writerAddress: string,

Check warning on line 2 in lib/airdrop/award.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'_writerAddress' is defined but never used
_storylineId: number,

Check warning on line 3 in lib/airdrop/award.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'_storylineId' is defined but never used
_timestamp?: Date,

Check warning on line 4 in lib/airdrop/award.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'_timestamp' is defined but never used
): Promise<void> {
const now = timestamp ?? new Date();
if (now < AIRDROP_CONFIG.CAMPAIGN_START || now > AIRDROP_CONFIG.CAMPAIGN_END) return;

const supabase = createServiceRoleClient();
if (!supabase) return;

const address = writerAddress.toLowerCase();

// Dedup check
const { data: existing } = await supabase
.from("pl_points")
.select("id")
.eq("action", "write")
.eq("address", address)
.eq("metadata->>storyline_id", String(storylineId))
.limit(1);

if (existing && existing.length > 0) return;

// Look up streak
const { data: streak } = await supabase
.from("pl_streaks")
.select("current_streak")
.eq("address", address)
.single();

const boost = getStreakBoost(streak?.current_streak ?? 0);
const points = AIRDROP_CONFIG.POINTS.WRITE_FLAT * (1 + boost);

await supabase.from("pl_points").insert({
address,
action: "write",
points,
metadata: { storyline_id: storylineId },
});
return;
}

/**
* Award rate points (5 PL) for rating a story.
* Capped at RATE_DAILY_CAP per day per address.
* Dedup via metadata.storyline_id + address.
*/
export async function awardRatePoints(
raterAddress: string,
storylineId: number,
_raterAddress: string,

Check warning on line 10 in lib/airdrop/award.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'_raterAddress' is defined but never used
_storylineId: number,

Check warning on line 11 in lib/airdrop/award.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'_storylineId' is defined but never used
): Promise<void> {
const now = new Date();
if (now < AIRDROP_CONFIG.CAMPAIGN_START || now > AIRDROP_CONFIG.CAMPAIGN_END) return;

const supabase = createServiceRoleClient();
if (!supabase) return;

const address = raterAddress.toLowerCase();

// Dedup check (one rating per story per user)
const { data: existing } = await supabase
.from("pl_points")
.select("id")
.eq("action", "rate")
.eq("address", address)
.eq("metadata->>storyline_id", String(storylineId))
.limit(1);

if (existing && existing.length > 0) return;

// Daily cap check
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);

const { count } = await supabase
.from("pl_points")
.select("id", { count: "exact", head: true })
.eq("action", "rate")
.eq("address", address)
.gte("created_at", todayStart.toISOString());

if ((count ?? 0) >= AIRDROP_CONFIG.POINTS.RATE_DAILY_CAP) return;

// Look up streak
const { data: streak } = await supabase
.from("pl_streaks")
.select("current_streak")
.eq("address", address)
.single();

const boost = getStreakBoost(streak?.current_streak ?? 0);
const points = AIRDROP_CONFIG.POINTS.RATE_FLAT * (1 + boost);

await supabase.from("pl_points").insert({
address,
action: "rate",
points,
metadata: { storyline_id: storylineId },
});
return;
}
30 changes: 2 additions & 28 deletions lib/airdrop/points.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,10 @@
/**
* Airdrop points helpers (#881)
*
* Computes buy points with streak boost, and referral points.
*/

import { AIRDROP_CONFIG } from "./config";
import { getStreakBoost } from "./streak";

export { getStreakBoost };

/**
* 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 boosted buy points).
* Also boosted by the referrer's own streak.
*/
export function computeReferralPoints(
buyerBoostedPoints: number,
referrerStreak: number,
_currentStreak: number,

Check warning on line 7 in lib/airdrop/points.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'_currentStreak' is defined but never used
): number {
const base = buyerBoostedPoints * (AIRDROP_CONFIG.POINTS.REFERRAL_PCT / 100);
const boost = getStreakBoost(referrerStreak);
return base * (1 + boost);
return plotSpent;
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "plotlink",
"version": "1.30.0",
"version": "1.30.1",
"private": true,
"workspaces": [
"packages/*"
Expand Down
142 changes: 2 additions & 140 deletions src/app/api/airdrop/checkin/route.ts
Original file line number Diff line number Diff line change
@@ -1,141 +1,3 @@
/**
* 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 { createServerClient } from "../../../../../lib/supabase";
import { AIRDROP_CONFIG } from "../../../../../lib/airdrop/config";
import { getStreakBoost, dropOneTier, getNextTier } from "../../../../../lib/airdrop/streak";
import { verifyWalletOwnership } from "../../../../../lib/airdrop/verify-wallet";
import { checkRateLimit, getClientIp } from "../../../../../lib/rate-limit";

export async function POST(req: Request) {
const ip = getClientIp(req);
if (!(await checkRateLimit(ip, "airdrop/checkin"))) {
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: `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 });
}

// Verify wallet ownership via SIWE signature
const claimedAddress = await verifyWalletOwnership(message, signature);
if (!claimedAddress) {
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 to previous tier threshold
newStreak = dropOneTier(existing.current_streak);
}
} 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,
});
export async function POST() {
return new Response(null, { status: 410 });
}
Loading
Loading