From 64ef435e32e5ee2af215ffaec4894ce8d237472f Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 30 Mar 2026 20:49:44 +0100 Subject: [PATCH 1/6] [#636] Add auth to real-time indexer endpoints - New lib/index-auth.ts: verifyIndexAuth() checks NEXT_PUBLIC_INDEX_TOKEN Bearer header, fails closed in production - All 4 indexer routes (trade, storyline, plot, donation) now require auth - New lib/index-fetch.ts: indexFetch() wrapper auto-includes auth header - Updated all frontend callers to use indexFetch() Unauthenticated calls return 401. Prevents DoS via RPC cost amplification. Fixes realproject7/plotlink#636 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/index-auth.ts | 16 ++++++++++++++++ lib/index-fetch.ts | 17 +++++++++++++++++ src/app/api/index/donation/route.ts | 4 ++++ src/app/api/index/plot/route.ts | 4 ++++ src/app/api/index/storyline/route.ts | 4 ++++ src/app/api/index/trade/route.ts | 4 ++++ src/components/DonateWidget.tsx | 7 ++----- src/components/TradingWidget.tsx | 7 ++----- src/hooks/usePublish.ts | 7 ++----- 9 files changed, 55 insertions(+), 15 deletions(-) create mode 100644 lib/index-auth.ts create mode 100644 lib/index-fetch.ts diff --git a/lib/index-auth.ts b/lib/index-auth.ts new file mode 100644 index 00000000..f368b6b4 --- /dev/null +++ b/lib/index-auth.ts @@ -0,0 +1,16 @@ +/** + * Shared auth check for real-time indexer endpoints. + * Uses NEXT_PUBLIC_INDEX_TOKEN as a lightweight API key. + * Fails closed in production when the token is unset. + */ + +const INDEX_TOKEN = process.env.NEXT_PUBLIC_INDEX_TOKEN; + +export function verifyIndexAuth(req: Request): boolean { + if (!INDEX_TOKEN) { + // Allow in dev, fail closed in production + return process.env.NODE_ENV !== "production"; + } + const authHeader = req.headers.get("authorization"); + return authHeader === `Bearer ${INDEX_TOKEN}`; +} diff --git a/lib/index-fetch.ts b/lib/index-fetch.ts new file mode 100644 index 00000000..c305379b --- /dev/null +++ b/lib/index-fetch.ts @@ -0,0 +1,17 @@ +/** + * Fetch wrapper for real-time indexer endpoints. + * Automatically includes the INDEX_TOKEN auth header. + */ + +const INDEX_TOKEN = process.env.NEXT_PUBLIC_INDEX_TOKEN; + +export function indexFetch(route: string, body: Record): Promise { + return fetch(route, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(INDEX_TOKEN ? { Authorization: `Bearer ${INDEX_TOKEN}` } : {}), + }, + body: JSON.stringify(body), + }); +} diff --git a/src/app/api/index/donation/route.ts b/src/app/api/index/donation/route.ts index e31b3c57..e95168d1 100644 --- a/src/app/api/index/donation/route.ts +++ b/src/app/api/index/donation/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { type Hex, decodeEventLog, encodeEventTopics } from "viem"; import { publicClient, getReceiptWithRetry } from "../../../../../lib/rpc"; import { createServerClient } from "../../../../../lib/supabase"; +import { verifyIndexAuth } from "../../../../../lib/index-auth"; import { storyFactoryAbi, donationEvent, @@ -20,6 +21,9 @@ function error(message: string, status = 400) { } export async function POST(req: Request) { + if (!verifyIndexAuth(req)) { + return error("Unauthorized", 401); + } const body = await req.json(); const txHash = body.txHash as Hex | undefined; diff --git a/src/app/api/index/plot/route.ts b/src/app/api/index/plot/route.ts index 650ce342..fe108f28 100644 --- a/src/app/api/index/plot/route.ts +++ b/src/app/api/index/plot/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { type Hex, decodeEventLog, encodeEventTopics } from "viem"; import { publicClient, getReceiptWithRetry } from "../../../../../lib/rpc"; import { createServerClient } from "../../../../../lib/supabase"; +import { verifyIndexAuth } from "../../../../../lib/index-auth"; import { storyFactoryAbi, plotChainedEvent, @@ -25,6 +26,9 @@ function error(message: string, status = 400) { } export async function POST(req: Request) { + if (!verifyIndexAuth(req)) { + return error("Unauthorized", 401); + } const body = await req.json(); const txHash = body.txHash as Hex | undefined; const fallbackContent = body.content as string | undefined; diff --git a/src/app/api/index/storyline/route.ts b/src/app/api/index/storyline/route.ts index fd125e18..fa2a3d3a 100644 --- a/src/app/api/index/storyline/route.ts +++ b/src/app/api/index/storyline/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { type Hex, decodeEventLog, encodeEventTopics } from "viem"; import { publicClient, getReceiptWithRetry } from "../../../../../lib/rpc"; import { createServerClient } from "../../../../../lib/supabase"; +import { verifyIndexAuth } from "../../../../../lib/index-auth"; import { storyFactoryAbi, storylineCreatedEvent, @@ -26,6 +27,9 @@ function error(message: string, status = 400) { } export async function POST(req: Request) { + if (!verifyIndexAuth(req)) { + return error("Unauthorized", 401); + } const body = await req.json(); const txHash = body.txHash as Hex | undefined; const fallbackContent = body.content as string | undefined; diff --git a/src/app/api/index/trade/route.ts b/src/app/api/index/trade/route.ts index 91a8613f..869a4375 100644 --- a/src/app/api/index/trade/route.ts +++ b/src/app/api/index/trade/route.ts @@ -5,6 +5,7 @@ import { createServerClient } from "../../../../../lib/supabase"; import { mcv2BondEventAbi, priceForNextMintFunction } from "../../../../../lib/contracts/abi"; import { MCV2_BOND, ZAP_PLOTLINK } from "../../../../../lib/contracts/constants"; import { erc20Abi } from "../../../../../lib/price"; +import { verifyIndexAuth } from "../../../../../lib/index-auth"; import type { Database } from "../../../../../lib/supabase"; type TradeInsert = Database["public"]["Tables"]["trade_history"]["Insert"]; @@ -14,6 +15,9 @@ function error(message: string, status = 400) { } export async function POST(req: Request) { + if (!verifyIndexAuth(req)) { + return error("Unauthorized", 401); + } const body = await req.json(); const txHash = body.txHash as Hex | undefined; const tokenAddress = (body.tokenAddress as string | undefined)?.toLowerCase(); diff --git a/src/components/DonateWidget.tsx b/src/components/DonateWidget.tsx index 2c705148..be110139 100644 --- a/src/components/DonateWidget.tsx +++ b/src/components/DonateWidget.tsx @@ -8,6 +8,7 @@ import { browserClient as publicClient } from "../../lib/rpc"; import { erc20Abi } from "../../lib/price"; import { storyFactoryAbi } from "../../lib/contracts/abi"; import { STORY_FACTORY, PLOT_TOKEN, RESERVE_LABEL, EXPLORER_URL } from "../../lib/contracts/constants"; +import { indexFetch } from "../../lib/index-fetch"; import { FarcasterAvatar } from "./FarcasterAvatar"; type TxState = "idle" | "approving" | "confirming" | "pending" | "indexing" | "done" | "error"; @@ -90,11 +91,7 @@ export function DonateWidget({ storylineId, writerAddress }: DonateWidgetProps) await publicClient.waitForTransactionReceipt({ hash }); setTxState("indexing"); - const indexRes = await fetch("/api/index/donation", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ txHash: hash }), - }); + const indexRes = await indexFetch("/api/index/donation", { txHash: hash }); if (!indexRes.ok) { throw new Error("Donation sent on-chain but indexing failed. It will appear after the next backfill."); } diff --git a/src/components/TradingWidget.tsx b/src/components/TradingWidget.tsx index 8ff508ed..efb51387 100644 --- a/src/components/TradingWidget.tsx +++ b/src/components/TradingWidget.tsx @@ -11,6 +11,7 @@ import { ZAP_PLOTLINK, SUPPORTED_ZAP_TOKENS, ETH_ADDRESS, } from "../../lib/contracts/constants"; import { getZapQuote, buildZapMintTx } from "../../lib/zap"; +import { indexFetch } from "../../lib/index-fetch"; type Tab = "buy" | "sell"; type TxState = "idle" | "approving" | "confirming" | "pending" | "done" | "error"; @@ -399,11 +400,7 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { // Index the trade for price history (fire-and-forget) if (tradeHash) { - fetch("/api/index/trade", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ txHash: tradeHash, tokenAddress }), - }).catch(() => {}); + indexFetch("/api/index/trade", { txHash: tradeHash, tokenAddress }).catch(() => {}); } } catch (err) { setError(err instanceof Error ? err.message : "Transaction failed"); diff --git a/src/hooks/usePublish.ts b/src/hooks/usePublish.ts index 37e84d68..b18c825d 100644 --- a/src/hooks/usePublish.ts +++ b/src/hooks/usePublish.ts @@ -4,6 +4,7 @@ import { useState, useCallback, useRef } from "react"; import { useWriteContract } from "wagmi"; import { hashContent } from "../../lib/content"; import { browserClient as publicClient } from "../../lib/rpc"; +import { indexFetch } from "../../lib/index-fetch"; import type { Hex, Abi, TransactionReceipt } from "viem"; export type PublishState = @@ -113,11 +114,7 @@ export function usePublish() { // 4. Trigger indexer setState("indexing"); - const indexerRes = await fetch(opts.indexerRoute, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ txHash: hash, content: opts.content, ...opts.metadata }), - }); + const indexerRes = await indexFetch(opts.indexerRoute, { txHash: hash, content: opts.content, ...opts.metadata }); // Only clear intent on success (2xx) or 409 (already indexed) if (indexerRes.ok || indexerRes.status === 409) { From 020f07c9e9b9b8eb65fbaffbb0bfab38e20d10dd Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 30 Mar 2026 20:51:56 +0100 Subject: [PATCH 2/6] [#636] Rename to NEXT_PUBLIC_INDEX_KEY, document as speed bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed from NEXT_PUBLIC_INDEX_TOKEN to NEXT_PUBLIC_INDEX_KEY and switched from Authorization Bearer to x-index-key header. Clearly documented that this is a speed bump against casual bot spam, not real auth — the key is in the client bundle by design. True protection comes from on-chain event verification in each indexer. Addresses T2b review feedback on PR #653. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/index-auth.ts | 17 +++++++++++------ lib/index-fetch.ts | 9 ++++++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/lib/index-auth.ts b/lib/index-auth.ts index f368b6b4..c89136c1 100644 --- a/lib/index-auth.ts +++ b/lib/index-auth.ts @@ -1,16 +1,21 @@ /** * Shared auth check for real-time indexer endpoints. - * Uses NEXT_PUBLIC_INDEX_TOKEN as a lightweight API key. - * Fails closed in production when the token is unset. + * + * Uses NEXT_PUBLIC_INDEX_KEY as a shared key. The client sends this + * via the x-index-key header. This is a speed bump against casual bot + * spam, not real auth — the key is embedded in the client bundle by design. + * True protection comes from on-chain event verification in each indexer. + * + * Fails closed in production when NEXT_PUBLIC_INDEX_KEY is unset. */ -const INDEX_TOKEN = process.env.NEXT_PUBLIC_INDEX_TOKEN; +const INDEX_KEY = process.env.NEXT_PUBLIC_INDEX_KEY; export function verifyIndexAuth(req: Request): boolean { - if (!INDEX_TOKEN) { + if (!INDEX_KEY) { // Allow in dev, fail closed in production return process.env.NODE_ENV !== "production"; } - const authHeader = req.headers.get("authorization"); - return authHeader === `Bearer ${INDEX_TOKEN}`; + const key = req.headers.get("x-index-key"); + return key === INDEX_KEY; } diff --git a/lib/index-fetch.ts b/lib/index-fetch.ts index c305379b..59d156a8 100644 --- a/lib/index-fetch.ts +++ b/lib/index-fetch.ts @@ -1,16 +1,19 @@ /** * Fetch wrapper for real-time indexer endpoints. - * Automatically includes the INDEX_TOKEN auth header. + * Automatically includes the x-index-key header. + * + * NEXT_PUBLIC_INDEX_KEY is a speed bump against casual bot spam, + * not a secret — it is embedded in the client bundle by design. */ -const INDEX_TOKEN = process.env.NEXT_PUBLIC_INDEX_TOKEN; +const INDEX_KEY = process.env.NEXT_PUBLIC_INDEX_KEY; export function indexFetch(route: string, body: Record): Promise { return fetch(route, { method: "POST", headers: { "Content-Type": "application/json", - ...(INDEX_TOKEN ? { Authorization: `Bearer ${INDEX_TOKEN}` } : {}), + ...(INDEX_KEY ? { "x-index-key": INDEX_KEY } : {}), }, body: JSON.stringify(body), }); From a773726178148886402f3c3337354487e3261e29 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 30 Mar 2026 20:54:27 +0100 Subject: [PATCH 3/6] [#636] Use server action proxy with server-only INDEX_SECRET MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced client-exposed NEXT_PUBLIC_INDEX_KEY with server-only INDEX_SECRET. The client never sees the secret — indexFetch is now a "use server" action that runs server-side and injects the secret before calling the indexer routes. Addresses T2a + T2b review: the secret now has a real server-side trust boundary. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/index-auth.ts | 14 ++++++-------- lib/index-fetch.ts | 26 +++++++++++++++++--------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/lib/index-auth.ts b/lib/index-auth.ts index c89136c1..aeacf1b3 100644 --- a/lib/index-auth.ts +++ b/lib/index-auth.ts @@ -1,21 +1,19 @@ /** * Shared auth check for real-time indexer endpoints. * - * Uses NEXT_PUBLIC_INDEX_KEY as a shared key. The client sends this - * via the x-index-key header. This is a speed bump against casual bot - * spam, not real auth — the key is embedded in the client bundle by design. - * True protection comes from on-chain event verification in each indexer. + * Uses server-only INDEX_SECRET. The client never sees this value — + * frontend calls go through a server action proxy that injects it. * - * Fails closed in production when NEXT_PUBLIC_INDEX_KEY is unset. + * Fails closed in production when INDEX_SECRET is unset. */ -const INDEX_KEY = process.env.NEXT_PUBLIC_INDEX_KEY; +const INDEX_SECRET = process.env.INDEX_SECRET; export function verifyIndexAuth(req: Request): boolean { - if (!INDEX_KEY) { + if (!INDEX_SECRET) { // Allow in dev, fail closed in production return process.env.NODE_ENV !== "production"; } const key = req.headers.get("x-index-key"); - return key === INDEX_KEY; + return key === INDEX_SECRET; } diff --git a/lib/index-fetch.ts b/lib/index-fetch.ts index 59d156a8..005d29b2 100644 --- a/lib/index-fetch.ts +++ b/lib/index-fetch.ts @@ -1,20 +1,28 @@ +"use server"; + /** - * Fetch wrapper for real-time indexer endpoints. - * Automatically includes the x-index-key header. - * - * NEXT_PUBLIC_INDEX_KEY is a speed bump against casual bot spam, - * not a secret — it is embedded in the client bundle by design. + * Server action proxy for real-time indexer endpoints. + * Injects the server-only INDEX_SECRET so the client never sees it. */ -const INDEX_KEY = process.env.NEXT_PUBLIC_INDEX_KEY; +const INDEX_SECRET = process.env.INDEX_SECRET; + +export async function indexFetch( + route: string, + body: Record, +): Promise<{ ok: boolean; status: number }> { + const baseUrl = process.env.NEXT_PUBLIC_SITE_URL + || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : null) + || "http://localhost:3000"; -export function indexFetch(route: string, body: Record): Promise { - return fetch(route, { + const res = await fetch(`${baseUrl}${route}`, { method: "POST", headers: { "Content-Type": "application/json", - ...(INDEX_KEY ? { "x-index-key": INDEX_KEY } : {}), + ...(INDEX_SECRET ? { "x-index-key": INDEX_SECRET } : {}), }, body: JSON.stringify(body), }); + + return { ok: res.ok, status: res.status }; } From 2daf8e394ed1a8df94a4decf215554628d35a926 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 30 Mar 2026 20:57:31 +0100 Subject: [PATCH 4/6] [#636] Pin routes to whitelist, add origin validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route whitelist: only 4 known indexer paths allowed, prevents SSRF - Origin validation: rejects server action calls not from our own site - Combined with server-only INDEX_SECRET on the route handlers Three-layer defense: origin check → route whitelist → INDEX_SECRET. Addresses T2a review: arbitrary route SSRF and public callability. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/index-fetch.ts | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/lib/index-fetch.ts b/lib/index-fetch.ts index 005d29b2..1aa30c89 100644 --- a/lib/index-fetch.ts +++ b/lib/index-fetch.ts @@ -1,21 +1,46 @@ "use server"; +import { headers } from "next/headers"; + /** * Server action proxy for real-time indexer endpoints. - * Injects the server-only INDEX_SECRET so the client never sees it. + * + * Security layers: + * 1. Route whitelist — only the 4 known indexer paths, prevents SSRF + * 2. Origin validation — rejects calls not originating from our own site + * 3. Server-only INDEX_SECRET — injected into the internal fetch */ const INDEX_SECRET = process.env.INDEX_SECRET; +const ALLOWED_ROUTES = new Set([ + "/api/index/trade", + "/api/index/storyline", + "/api/index/plot", + "/api/index/donation", +]); + export async function indexFetch( route: string, body: Record, ): Promise<{ ok: boolean; status: number }> { - const baseUrl = process.env.NEXT_PUBLIC_SITE_URL + // Whitelist check — prevent SSRF + if (!ALLOWED_ROUTES.has(route)) { + return { ok: false, status: 400 }; + } + + // Origin validation — reject calls not from our own site + const headerStore = await headers(); + const origin = headerStore.get("origin"); + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : null) || "http://localhost:3000"; - const res = await fetch(`${baseUrl}${route}`, { + if (origin && !siteUrl.startsWith(origin)) { + return { ok: false, status: 403 }; + } + + const res = await fetch(`${siteUrl}${route}`, { method: "POST", headers: { "Content-Type": "application/json", From 806e0982f2d54e2f2cf4339a33091b23c0277a98 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 30 Mar 2026 21:01:31 +0100 Subject: [PATCH 5/6] [#636] Replace auth token with tx hash validation (existence + recency) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pivoted from shared-secret auth to tx hash validation: - validateRecentTx() checks tx exists, succeeded, and is < 5 min old - Rejects fake/stale hashes with 1 cheap RPC call before expensive processing - Removed INDEX_SECRET / server action proxy — no shared secret needed - indexFetch reverted to simple client-side wrapper This addresses the actual DoS vector: fake tx hashes causing expensive multi-retry receipt fetches + block lookups + contract reads. An attacker would need real, recent, successful on-chain transactions to trigger work. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/index-auth.ts | 37 +++++++++++++------- lib/index-fetch.ts | 52 ++++------------------------ src/app/api/index/donation/route.ts | 19 +++------- src/app/api/index/plot/route.ts | 19 +++------- src/app/api/index/storyline/route.ts | 19 +++------- src/app/api/index/trade/route.ts | 15 ++++---- 6 files changed, 54 insertions(+), 107 deletions(-) diff --git a/lib/index-auth.ts b/lib/index-auth.ts index aeacf1b3..799f5323 100644 --- a/lib/index-auth.ts +++ b/lib/index-auth.ts @@ -1,19 +1,32 @@ /** - * Shared auth check for real-time indexer endpoints. + * Tx hash validation for real-time indexer endpoints. * - * Uses server-only INDEX_SECRET. The client never sees this value — - * frontend calls go through a server action proxy that injects it. - * - * Fails closed in production when INDEX_SECRET is unset. + * Prevents DoS by rejecting invalid or stale tx hashes before expensive + * processing (multi-retry receipt fetch, block lookup, contract reads). + * A single non-retry getTransactionReceipt is cheap (~1 RPC call). + */ + +import { type Hex } from "viem"; +import { publicClient } from "./rpc"; + +const MAX_TX_AGE_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Validate that a tx hash corresponds to a real, recent transaction. + * Returns the receipt if valid, or null if the tx is missing/stale. */ +export async function validateRecentTx(txHash: Hex) { + try { + const receipt = await publicClient.getTransactionReceipt({ hash: txHash }); + if (!receipt || receipt.status !== "success") return null; -const INDEX_SECRET = process.env.INDEX_SECRET; + // Check recency via block timestamp + const block = await publicClient.getBlock({ blockNumber: receipt.blockNumber }); + const txAgeMs = Date.now() - Number(block.timestamp) * 1000; + if (txAgeMs > MAX_TX_AGE_MS) return null; -export function verifyIndexAuth(req: Request): boolean { - if (!INDEX_SECRET) { - // Allow in dev, fail closed in production - return process.env.NODE_ENV !== "production"; + return receipt; + } catch { + return null; } - const key = req.headers.get("x-index-key"); - return key === INDEX_SECRET; } diff --git a/lib/index-fetch.ts b/lib/index-fetch.ts index 1aa30c89..d3287b2d 100644 --- a/lib/index-fetch.ts +++ b/lib/index-fetch.ts @@ -1,53 +1,13 @@ -"use server"; - -import { headers } from "next/headers"; - /** - * Server action proxy for real-time indexer endpoints. - * - * Security layers: - * 1. Route whitelist — only the 4 known indexer paths, prevents SSRF - * 2. Origin validation — rejects calls not originating from our own site - * 3. Server-only INDEX_SECRET — injected into the internal fetch + * Fetch wrapper for real-time indexer endpoints. + * Indexer routes validate tx hashes server-side (existence + recency), + * so no auth token is needed from the client. */ -const INDEX_SECRET = process.env.INDEX_SECRET; - -const ALLOWED_ROUTES = new Set([ - "/api/index/trade", - "/api/index/storyline", - "/api/index/plot", - "/api/index/donation", -]); - -export async function indexFetch( - route: string, - body: Record, -): Promise<{ ok: boolean; status: number }> { - // Whitelist check — prevent SSRF - if (!ALLOWED_ROUTES.has(route)) { - return { ok: false, status: 400 }; - } - - // Origin validation — reject calls not from our own site - const headerStore = await headers(); - const origin = headerStore.get("origin"); - const siteUrl = process.env.NEXT_PUBLIC_SITE_URL - || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : null) - || "http://localhost:3000"; - - if (origin && !siteUrl.startsWith(origin)) { - return { ok: false, status: 403 }; - } - - const res = await fetch(`${siteUrl}${route}`, { +export function indexFetch(route: string, body: Record): Promise { + return fetch(route, { method: "POST", - headers: { - "Content-Type": "application/json", - ...(INDEX_SECRET ? { "x-index-key": INDEX_SECRET } : {}), - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - - return { ok: res.ok, status: res.status }; } diff --git a/src/app/api/index/donation/route.ts b/src/app/api/index/donation/route.ts index e95168d1..727c769c 100644 --- a/src/app/api/index/donation/route.ts +++ b/src/app/api/index/donation/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { type Hex, decodeEventLog, encodeEventTopics } from "viem"; import { publicClient, getReceiptWithRetry } from "../../../../../lib/rpc"; import { createServerClient } from "../../../../../lib/supabase"; -import { verifyIndexAuth } from "../../../../../lib/index-auth"; +import { validateRecentTx } from "../../../../../lib/index-auth"; import { storyFactoryAbi, donationEvent, @@ -21,9 +21,6 @@ function error(message: string, status = 400) { } export async function POST(req: Request) { - if (!verifyIndexAuth(req)) { - return error("Unauthorized", 401); - } const body = await req.json(); const txHash = body.txHash as Hex | undefined; @@ -31,16 +28,10 @@ export async function POST(req: Request) { return error("Missing or invalid txHash"); } - // 1. Fetch receipt (with retry for load-balanced RPC nodes) - let receipt; - try { - receipt = await getReceiptWithRetry(txHash); - } catch { - return error("Failed to fetch transaction receipt", 502); - } - - if (receipt.status !== "success") { - return error("Transaction failed"); + // 1. Validate tx exists and is recent (< 5 min) — prevents spam + const receipt = await validateRecentTx(txHash); + if (!receipt) { + return error("Transaction not found, failed, or too old"); } // 2. Find Donation event log by event signature (topic0) diff --git a/src/app/api/index/plot/route.ts b/src/app/api/index/plot/route.ts index fe108f28..5831166a 100644 --- a/src/app/api/index/plot/route.ts +++ b/src/app/api/index/plot/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { type Hex, decodeEventLog, encodeEventTopics } from "viem"; import { publicClient, getReceiptWithRetry } from "../../../../../lib/rpc"; import { createServerClient } from "../../../../../lib/supabase"; -import { verifyIndexAuth } from "../../../../../lib/index-auth"; +import { validateRecentTx } from "../../../../../lib/index-auth"; import { storyFactoryAbi, plotChainedEvent, @@ -26,9 +26,6 @@ function error(message: string, status = 400) { } export async function POST(req: Request) { - if (!verifyIndexAuth(req)) { - return error("Unauthorized", 401); - } const body = await req.json(); const txHash = body.txHash as Hex | undefined; const fallbackContent = body.content as string | undefined; @@ -37,16 +34,10 @@ export async function POST(req: Request) { return error("Missing or invalid txHash"); } - // 1. Fetch receipt (with retry for load-balanced RPC nodes) - let receipt; - try { - receipt = await getReceiptWithRetry(txHash); - } catch { - return error("Failed to fetch transaction receipt", 502); - } - - if (receipt.status !== "success") { - return error("Transaction failed"); + // 1. Validate tx exists and is recent (< 5 min) — prevents spam + const receipt = await validateRecentTx(txHash); + if (!receipt) { + return error("Transaction not found, failed, or too old"); } // 2. Find PlotChained event log by event signature (topic0) diff --git a/src/app/api/index/storyline/route.ts b/src/app/api/index/storyline/route.ts index fa2a3d3a..1fb9e3e2 100644 --- a/src/app/api/index/storyline/route.ts +++ b/src/app/api/index/storyline/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { type Hex, decodeEventLog, encodeEventTopics } from "viem"; import { publicClient, getReceiptWithRetry } from "../../../../../lib/rpc"; import { createServerClient } from "../../../../../lib/supabase"; -import { verifyIndexAuth } from "../../../../../lib/index-auth"; +import { validateRecentTx } from "../../../../../lib/index-auth"; import { storyFactoryAbi, storylineCreatedEvent, @@ -27,9 +27,6 @@ function error(message: string, status = 400) { } export async function POST(req: Request) { - if (!verifyIndexAuth(req)) { - return error("Unauthorized", 401); - } const body = await req.json(); const txHash = body.txHash as Hex | undefined; const fallbackContent = body.content as string | undefined; @@ -42,16 +39,10 @@ export async function POST(req: Request) { return error("Missing or invalid txHash"); } - // 1. Fetch receipt (with retry for load-balanced RPC nodes) - let receipt; - try { - receipt = await getReceiptWithRetry(txHash); - } catch { - return error("Failed to fetch transaction receipt", 502); - } - - if (receipt.status !== "success") { - return error("Transaction failed"); + // 1. Validate tx exists and is recent (< 5 min) — prevents spam + const receipt = await validateRecentTx(txHash); + if (!receipt) { + return error("Transaction not found, failed, or too old"); } // 2. Find StorylineCreated event log by event signature (topic0) diff --git a/src/app/api/index/trade/route.ts b/src/app/api/index/trade/route.ts index 869a4375..5930bbf2 100644 --- a/src/app/api/index/trade/route.ts +++ b/src/app/api/index/trade/route.ts @@ -5,7 +5,7 @@ import { createServerClient } from "../../../../../lib/supabase"; import { mcv2BondEventAbi, priceForNextMintFunction } from "../../../../../lib/contracts/abi"; import { MCV2_BOND, ZAP_PLOTLINK } from "../../../../../lib/contracts/constants"; import { erc20Abi } from "../../../../../lib/price"; -import { verifyIndexAuth } from "../../../../../lib/index-auth"; +import { validateRecentTx } from "../../../../../lib/index-auth"; import type { Database } from "../../../../../lib/supabase"; type TradeInsert = Database["public"]["Tables"]["trade_history"]["Insert"]; @@ -15,16 +15,17 @@ function error(message: string, status = 400) { } export async function POST(req: Request) { - if (!verifyIndexAuth(req)) { - return error("Unauthorized", 401); - } const body = await req.json(); const txHash = body.txHash as Hex | undefined; const tokenAddress = (body.tokenAddress as string | undefined)?.toLowerCase(); - if (!txHash) return error("txHash required"); + if (!txHash || !/^0x[0-9a-fA-F]{64}$/.test(txHash)) return error("Missing or invalid txHash"); if (!tokenAddress) return error("tokenAddress required"); + // Validate tx exists and is recent (< 5 min) — prevents spam with fake hashes + const validatedReceipt = await validateRecentTx(txHash); + if (!validatedReceipt) return error("Transaction not found, failed, or too old", 400); + const supabase = createServerClient(); if (!supabase) return error("Supabase not configured", 500); @@ -37,8 +38,8 @@ export async function POST(req: Request) { if (!storyline) return error("Unknown token address", 404); - const receipt = await getReceiptWithRetry(txHash); - if (!receipt) return error("Receipt not found", 404); + // Use validated receipt, fall back to retry for load-balanced RPC consistency + const receipt = validatedReceipt; // Retry getBlock — RPC may not have the block yet on load-balanced nodes let timestampISO: string; From 0f158af5f1755121471efb0c324849a3d0915460 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 30 Mar 2026 21:05:26 +0100 Subject: [PATCH 6/6] [#636] Use getReceiptWithRetry in validateRecentTx for RPC reliability Restored retry behavior so freshly-confirmed txs aren't rejected on lagging/load-balanced RPC nodes. validateRecentTx now uses getReceiptWithRetry (same as before) plus the recency check. Addresses T2a review: single-call receipt fetch could reject legit txs. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/index-auth.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/index-auth.ts b/lib/index-auth.ts index 799f5323..0523d894 100644 --- a/lib/index-auth.ts +++ b/lib/index-auth.ts @@ -1,23 +1,24 @@ /** * Tx hash validation for real-time indexer endpoints. * - * Prevents DoS by rejecting invalid or stale tx hashes before expensive - * processing (multi-retry receipt fetch, block lookup, contract reads). - * A single non-retry getTransactionReceipt is cheap (~1 RPC call). + * Prevents DoS by rejecting stale tx hashes before expensive processing. + * Uses getReceiptWithRetry for load-balanced RPC reliability, then checks + * block timestamp recency. */ import { type Hex } from "viem"; -import { publicClient } from "./rpc"; +import { publicClient, getReceiptWithRetry } from "./rpc"; const MAX_TX_AGE_MS = 5 * 60 * 1000; // 5 minutes /** - * Validate that a tx hash corresponds to a real, recent transaction. - * Returns the receipt if valid, or null if the tx is missing/stale. + * Validate that a tx hash corresponds to a real, recent, successful transaction. + * Uses retry logic for load-balanced RPC nodes. + * Returns the receipt if valid, or null if the tx is missing/failed/stale. */ export async function validateRecentTx(txHash: Hex) { try { - const receipt = await publicClient.getTransactionReceipt({ hash: txHash }); + const receipt = await getReceiptWithRetry(txHash); if (!receipt || receipt.status !== "success") return null; // Check recency via block timestamp