From b5e88a829d48eff9706661ab2f97a5422a1255c8 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 25 Mar 2026 08:57:55 +0000 Subject: [PATCH 1/3] [#521] Targeted notifications for token holders - Add wallet_address column to notification_tokens table - Create notification_queue table for batched targeting - Create token_price_snapshots table for price change detection - Update notifyNewPlot to target only storyline token holders (falls back to all users if no holders have notification tokens) - Add checkPriceChangeAlert for >10% price movement alerts - Create /api/cron/price-alerts route for periodic price checking - Accept walletAddress in save-token API route - Bump version to 0.1.6 Fixes #521 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/notifications.server.ts | 174 ++++++++++++++++-- package.json | 2 +- src/app/api/cron/price-alerts/route.ts | 67 +++++++ src/app/api/notifications/save-token/route.ts | 4 +- .../00022_notification_targeting.sql | 48 +++++ 5 files changed, 275 insertions(+), 20 deletions(-) create mode 100644 src/app/api/cron/price-alerts/route.ts create mode 100644 supabase/migrations/00022_notification_targeting.sql diff --git a/lib/notifications.server.ts b/lib/notifications.server.ts index a64a7a43..9ee8c08a 100644 --- a/lib/notifications.server.ts +++ b/lib/notifications.server.ts @@ -1,11 +1,17 @@ /** - * [#489] Farcaster notification system for PlotLink. + * [#489, #521] Farcaster notification system for PlotLink. * * Handles notification token storage (Supabase) and sending push * notifications to Farcaster clients via the miniapp notification API. + * + * [#521] Targeted notifications: new plot notifications go only to + * storyline token holders. Price change alerts (>10%) sent to holders. */ import { createClient } from "@supabase/supabase-js"; +import { createPublicClient, http, erc20Abi, type Address } from "viem"; +import { base } from "viem/chains"; +import { STORY_FACTORY } from "./contracts/constants"; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ""; const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || ""; @@ -16,6 +22,13 @@ function getSupabase() { }); } +function getRpcClient() { + return createPublicClient({ + chain: base, + transport: http(process.env.NEXT_PUBLIC_RPC_URL), + }); +} + export interface NotificationToken { fid: number; notificationToken: string; @@ -29,20 +42,25 @@ export async function saveUserNotificationToken( token: string, url: string, clientAppFid?: number, + walletAddress?: string, ): Promise { const supabase = getSupabase(); - const { error } = await supabase.from("notification_tokens").upsert( - { - fid, - notification_token: token, - notification_url: url, - client_app_fid: clientAppFid || null, - enabled: true, - updated_at: new Date().toISOString(), - }, - { onConflict: "fid" }, - ); + const row: Record = { + fid, + notification_token: token, + notification_url: url, + client_app_fid: clientAppFid || null, + enabled: true, + updated_at: new Date().toISOString(), + }; + if (walletAddress) { + row.wallet_address = walletAddress.toLowerCase(); + } + + const { error } = await supabase + .from("notification_tokens") + .upsert(row, { onConflict: "fid" }); if (error) { console.error("Failed to save notification token:", error); @@ -83,6 +101,51 @@ export async function getEnabledTokens(): Promise { })); } +// ---- [#521] Token Holder Targeting ---- + +/** + * Get notification tokens for users who hold a specific storyline token. + * Queries enabled tokens with wallet_address, then checks on-chain balanceOf. + */ +async function getTokenHolderTokens( + tokenAddress: string, +): Promise { + const supabase = getSupabase(); + const rpc = getRpcClient(); + + // Get all enabled tokens that have a wallet_address + const { data, error } = await supabase + .from("notification_tokens") + .select("*") + .eq("enabled", true) + .not("wallet_address", "is", null); + + if (error || !data || data.length === 0) return []; + + // Check on-chain balances via multicall + const balanceResults = await rpc.multicall({ + contracts: data.map((row) => ({ + address: tokenAddress as Address, + abi: erc20Abi, + functionName: "balanceOf" as const, + args: [row.wallet_address as Address], + })), + allowFailure: true, + }); + + // Filter to holders (balance > 0) + return data + .filter((_, i) => { + const result = balanceResults[i]; + return result.status === "success" && (result.result as bigint) > BigInt(0); + }) + .map((row) => ({ + fid: row.fid, + notificationToken: row.notification_token, + notificationUrl: row.notification_url, + })); +} + // ---- Notification Sending ---- export async function sendNotification(params: { @@ -182,19 +245,40 @@ export async function notifyNewStoryline( } /** - * Notify all users with enabled notifications about a new plot. - * Called from the backfill cron when a new plot is indexed. + * [#521] Notify token holders about a new plot in a storyline they hold. + * Falls back to all users if no token address or no holders found. */ export async function notifyNewPlot( storylineId: number, storyTitle: string, plotIndex: number, ): Promise { - const tokens = await getEnabledTokens(); - if (tokens.length === 0) return; - + const supabase = getSupabase(); const label = plotIndex === 0 ? "Genesis" : `Chapter ${plotIndex}`; + // Look up storyline token address + const { data: storyline } = await supabase + .from("storylines") + .select("token_address") + .eq("storyline_id", storylineId) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .single(); + + let tokens: NotificationToken[]; + + if (storyline?.token_address) { + // Target only token holders + tokens = await getTokenHolderTokens(storyline.token_address); + if (tokens.length === 0) { + // Fallback: send to all if no holders have notification tokens + tokens = await getEnabledTokens(); + } + } else { + tokens = await getEnabledTokens(); + } + + if (tokens.length === 0) return; + await sendNotification({ notificationId: `pl-new-plot-${storylineId}-${plotIndex}`, title: `New ${label} published`, @@ -203,3 +287,59 @@ export async function notifyNewPlot( tokens, }); } + +// ---- [#521] Price Change Alerts ---- + +const PRICE_CHANGE_THRESHOLD = 10; // percent + +/** + * Snapshot current price for a token and check for >10% change. + * If threshold exceeded, sends alert to all holders of that token. + */ +export async function checkPriceChangeAlert( + tokenAddress: string, + currentPrice: number, + storylineId: number, + storyTitle: string, +): Promise { + const supabase = getSupabase(); + + // Get previous snapshot + const { data: prev } = await supabase + .from("token_price_snapshots") + .select("price") + .eq("token_address", tokenAddress.toLowerCase()) + .order("snapshot_time", { ascending: false }) + .limit(1) + .single(); + + // Save current snapshot + await supabase.from("token_price_snapshots").insert({ + token_address: tokenAddress.toLowerCase(), + price: currentPrice, + }); + + if (!prev || !prev.price) return; + + const previousPrice = Number(prev.price); + if (previousPrice === 0) return; + + const changePercent = ((currentPrice - previousPrice) / previousPrice) * 100; + + if (Math.abs(changePercent) < PRICE_CHANGE_THRESHOLD) return; + + // Alert holders + const tokens = await getTokenHolderTokens(tokenAddress); + if (tokens.length === 0) return; + + const direction = changePercent > 0 ? "up" : "down"; + const absChange = Math.abs(changePercent).toFixed(1); + + await sendNotification({ + notificationId: `pl-price-alert-${tokenAddress}-${Date.now()}`, + title: `Price ${direction} ${absChange}%`, + body: `"${storyTitle.slice(0, 30)}" token moved ${direction} ${absChange}%`, + targetUrl: `${appUrl}/story/${storylineId}`, + tokens, + }); +} diff --git a/package.json b/package.json index c2368d4e..119c35b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "0.1.5", + "version": "0.1.6", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/api/cron/price-alerts/route.ts b/src/app/api/cron/price-alerts/route.ts new file mode 100644 index 00000000..2959d560 --- /dev/null +++ b/src/app/api/cron/price-alerts/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { createServerClient, type Storyline } from "../../../../../lib/supabase"; +import { getTokenPrice } from "../../../../../lib/price"; +import { publicClient } from "../../../../../lib/rpc"; +import { checkPriceChangeAlert } from "../../../../../lib/notifications.server"; +import { STORY_FACTORY } from "../../../../../lib/contracts/constants"; +import { type Address } from "viem"; + +/** + * [#521] Cron: check token prices and send alerts for >10% changes. + * Runs every ~5 minutes alongside the backfill cron. + */ +export async function GET(req: Request) { + const secret = process.env.CRON_SECRET; + if (secret) { + const authHeader = req.headers.get("authorization"); + if (authHeader !== `Bearer ${secret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } else if (process.env.NODE_ENV === "production") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const supabase = createServerClient(); + if (!supabase) { + return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); + } + + // Get all active storylines with tokens + const { data: storylines } = await supabase + .from("storylines") + .select("*") + .eq("hidden", false) + .eq("sunset", false) + .neq("token_address", "") + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .returns(); + + if (!storylines || storylines.length === 0) { + return NextResponse.json({ checked: 0 }); + } + + let checked = 0; + const alerts = 0; + + for (const sl of storylines) { + try { + const priceInfo = await getTokenPrice(sl.token_address as Address, publicClient); + if (!priceInfo) continue; + + const price = parseFloat(priceInfo.pricePerToken); + if (price <= 0) continue; + + await checkPriceChangeAlert( + sl.token_address, + price, + sl.storyline_id, + sl.title, + ); + checked++; + } catch { + // Skip individual token errors + } + } + + return NextResponse.json({ checked, alerts }); +} diff --git a/src/app/api/notifications/save-token/route.ts b/src/app/api/notifications/save-token/route.ts index 232fe18a..e1749669 100644 --- a/src/app/api/notifications/save-token/route.ts +++ b/src/app/api/notifications/save-token/route.ts @@ -7,13 +7,13 @@ import { saveUserNotificationToken } from "../../../../../lib/notifications.serv */ export async function POST(request: NextRequest) { try { - const { fid, token, url } = await request.json(); + const { fid, token, url, walletAddress } = await request.json(); if (!fid || !token || !url) { return NextResponse.json({ error: "Missing fields" }, { status: 400 }); } - await saveUserNotificationToken(fid, token, url); + await saveUserNotificationToken(fid, token, url, undefined, walletAddress); return NextResponse.json({ success: true }); } catch (error) { console.error("Failed to save notification token:", error); diff --git a/supabase/migrations/00022_notification_targeting.sql b/supabase/migrations/00022_notification_targeting.sql new file mode 100644 index 00000000..b7efa067 --- /dev/null +++ b/supabase/migrations/00022_notification_targeting.sql @@ -0,0 +1,48 @@ +-- [#521] Targeted notifications for token holders +-- +-- 1. Add wallet_address to notification_tokens (links FID → wallet for on-chain lookups) +-- 2. Create notification_queue for batched, targeted delivery +-- 3. Create token_price_snapshots for price change detection + +-- 1. wallet_address on notification_tokens +ALTER TABLE notification_tokens + ADD COLUMN IF NOT EXISTS wallet_address TEXT; + +CREATE INDEX IF NOT EXISTS idx_notification_tokens_wallet + ON notification_tokens (wallet_address) + WHERE wallet_address IS NOT NULL; + +-- 2. Notification queue +CREATE TABLE IF NOT EXISTS notification_queue ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + target_fid INTEGER NOT NULL, + notification_type TEXT NOT NULL, + storyline_id BIGINT, + title TEXT NOT NULL, + body TEXT NOT NULL, + target_url TEXT NOT NULL, + status TEXT DEFAULT 'pending' + CHECK (status IN ('pending', 'sent', 'failed', 'skipped')), + error_message TEXT, + scheduled_at TIMESTAMPTZ DEFAULT NOW(), + sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_notification_queue_status + ON notification_queue (status, scheduled_at); + +CREATE INDEX IF NOT EXISTS idx_notification_queue_dedup + ON notification_queue (target_fid, notification_type, storyline_id) + WHERE status = 'pending'; + +-- 3. Token price snapshots (for >10% change alerts) +CREATE TABLE IF NOT EXISTS token_price_snapshots ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + token_address TEXT NOT NULL, + price NUMERIC NOT NULL, + snapshot_time TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_price_snapshots_token_time + ON token_price_snapshots (token_address, snapshot_time DESC); From d66d6b1c700d806f27af1aed9a0839e40c6a0ecb Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 25 Mar 2026 09:00:26 +0000 Subject: [PATCH 2/3] [#521] Fix: increment alerts counter, validate walletAddress input - Fix alerts counter: let + increment after checkPriceChangeAlert returns true - checkPriceChangeAlert now returns boolean indicating if alert was sent - Validate walletAddress as Ethereum address format before storage Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/notifications.server.ts | 12 +++++++----- src/app/api/cron/price-alerts/route.ts | 5 +++-- src/app/api/notifications/save-token/route.ts | 7 ++++++- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/lib/notifications.server.ts b/lib/notifications.server.ts index 9ee8c08a..57fabd6b 100644 --- a/lib/notifications.server.ts +++ b/lib/notifications.server.ts @@ -301,7 +301,7 @@ export async function checkPriceChangeAlert( currentPrice: number, storylineId: number, storyTitle: string, -): Promise { +): Promise { const supabase = getSupabase(); // Get previous snapshot @@ -319,18 +319,18 @@ export async function checkPriceChangeAlert( price: currentPrice, }); - if (!prev || !prev.price) return; + if (!prev || !prev.price) return false; const previousPrice = Number(prev.price); - if (previousPrice === 0) return; + if (previousPrice === 0) return false; const changePercent = ((currentPrice - previousPrice) / previousPrice) * 100; - if (Math.abs(changePercent) < PRICE_CHANGE_THRESHOLD) return; + if (Math.abs(changePercent) < PRICE_CHANGE_THRESHOLD) return false; // Alert holders const tokens = await getTokenHolderTokens(tokenAddress); - if (tokens.length === 0) return; + if (tokens.length === 0) return false; const direction = changePercent > 0 ? "up" : "down"; const absChange = Math.abs(changePercent).toFixed(1); @@ -342,4 +342,6 @@ export async function checkPriceChangeAlert( targetUrl: `${appUrl}/story/${storylineId}`, tokens, }); + + return true; } diff --git a/src/app/api/cron/price-alerts/route.ts b/src/app/api/cron/price-alerts/route.ts index 2959d560..be0774ea 100644 --- a/src/app/api/cron/price-alerts/route.ts +++ b/src/app/api/cron/price-alerts/route.ts @@ -41,7 +41,7 @@ export async function GET(req: Request) { } let checked = 0; - const alerts = 0; + let alerts = 0; for (const sl of storylines) { try { @@ -51,13 +51,14 @@ export async function GET(req: Request) { const price = parseFloat(priceInfo.pricePerToken); if (price <= 0) continue; - await checkPriceChangeAlert( + const alerted = await checkPriceChangeAlert( sl.token_address, price, sl.storyline_id, sl.title, ); checked++; + if (alerted) alerts++; } catch { // Skip individual token errors } diff --git a/src/app/api/notifications/save-token/route.ts b/src/app/api/notifications/save-token/route.ts index e1749669..c1959e49 100644 --- a/src/app/api/notifications/save-token/route.ts +++ b/src/app/api/notifications/save-token/route.ts @@ -13,7 +13,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Missing fields" }, { status: 400 }); } - await saveUserNotificationToken(fid, token, url, undefined, walletAddress); + // Validate wallet address format if provided + const validatedWallet = walletAddress && /^0x[0-9a-fA-F]{40}$/.test(walletAddress) + ? walletAddress + : undefined; + + await saveUserNotificationToken(fid, token, url, undefined, validatedWallet); return NextResponse.json({ success: true }); } catch (error) { console.error("Failed to save notification token:", error); From be1839c94f6f8c6e89548954f6f49bff2d4c7bcb Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 25 Mar 2026 09:06:34 +0000 Subject: [PATCH 3/3] [#521] Fix: resolve wallet from Neynar API, remove broadcast fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wallet address now resolved server-side from FID via trusted Neynar API (verified_addresses.eth_addresses) instead of client-supplied input - Remove broadcast fallback in notifyNewPlot — only token holders notified - Revert client walletAddress param from save-token route Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/notifications.server.ts | 51 ++++++++++++++----- src/app/api/notifications/save-token/route.ts | 9 +--- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/lib/notifications.server.ts b/lib/notifications.server.ts index 57fabd6b..1ad22a23 100644 --- a/lib/notifications.server.ts +++ b/lib/notifications.server.ts @@ -13,6 +13,8 @@ import { createPublicClient, http, erc20Abi, type Address } from "viem"; import { base } from "viem/chains"; import { STORY_FACTORY } from "./contracts/constants"; +const NEYNAR_BASE = "https://api.neynar.com/v2/farcaster"; + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ""; const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || ""; @@ -42,10 +44,12 @@ export async function saveUserNotificationToken( token: string, url: string, clientAppFid?: number, - walletAddress?: string, ): Promise { const supabase = getSupabase(); + // Resolve wallet from FID via trusted Neynar API + const walletAddress = await resolveWalletForFid(fid); + const row: Record = { fid, notification_token: token, @@ -55,7 +59,7 @@ export async function saveUserNotificationToken( updated_at: new Date().toISOString(), }; if (walletAddress) { - row.wallet_address = walletAddress.toLowerCase(); + row.wallet_address = walletAddress; } const { error } = await supabase @@ -101,6 +105,34 @@ export async function getEnabledTokens(): Promise { })); } +// ---- [#521] FID → Wallet Resolution (trusted) ---- + +/** + * Resolve a Farcaster FID to its verified Ethereum address via Neynar API. + * Returns the first verified address, or null if unavailable. + */ +async function resolveWalletForFid(fid: number): Promise { + const apiKey = process.env.NEYNAR_API_KEY; + if (!apiKey) return null; + + try { + const res = await fetch(`${NEYNAR_BASE}/user/bulk?fids=${fid}`, { + headers: { accept: "application/json", "x-api-key": apiKey }, + signal: AbortSignal.timeout(3000), + }); + if (!res.ok) return null; + const json = await res.json(); + const user = json.users?.[0]; + if (!user) return null; + + // Use first verified Ethereum address + const verifiedAddress = user.verified_addresses?.eth_addresses?.[0]; + return verifiedAddress?.toLowerCase() ?? null; + } catch { + return null; + } +} + // ---- [#521] Token Holder Targeting ---- /** @@ -264,19 +296,10 @@ export async function notifyNewPlot( .eq("contract_address", STORY_FACTORY.toLowerCase()) .single(); - let tokens: NotificationToken[]; - - if (storyline?.token_address) { - // Target only token holders - tokens = await getTokenHolderTokens(storyline.token_address); - if (tokens.length === 0) { - // Fallback: send to all if no holders have notification tokens - tokens = await getEnabledTokens(); - } - } else { - tokens = await getEnabledTokens(); - } + // Only notify token holders — no broadcast fallback + if (!storyline?.token_address) return; + const tokens = await getTokenHolderTokens(storyline.token_address); if (tokens.length === 0) return; await sendNotification({ diff --git a/src/app/api/notifications/save-token/route.ts b/src/app/api/notifications/save-token/route.ts index c1959e49..232fe18a 100644 --- a/src/app/api/notifications/save-token/route.ts +++ b/src/app/api/notifications/save-token/route.ts @@ -7,18 +7,13 @@ import { saveUserNotificationToken } from "../../../../../lib/notifications.serv */ export async function POST(request: NextRequest) { try { - const { fid, token, url, walletAddress } = await request.json(); + const { fid, token, url } = await request.json(); if (!fid || !token || !url) { return NextResponse.json({ error: "Missing fields" }, { status: 400 }); } - // Validate wallet address format if provided - const validatedWallet = walletAddress && /^0x[0-9a-fA-F]{40}$/.test(walletAddress) - ? walletAddress - : undefined; - - await saveUserNotificationToken(fid, token, url, undefined, validatedWallet); + await saveUserNotificationToken(fid, token, url); return NextResponse.json({ success: true }); } catch (error) { console.error("Failed to save notification token:", error);