diff --git a/lib/index-auth.ts b/lib/index-auth.ts new file mode 100644 index 00000000..0523d894 --- /dev/null +++ b/lib/index-auth.ts @@ -0,0 +1,33 @@ +/** + * Tx hash validation for real-time indexer endpoints. + * + * 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, getReceiptWithRetry } from "./rpc"; + +const MAX_TX_AGE_MS = 5 * 60 * 1000; // 5 minutes + +/** + * 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 getReceiptWithRetry(txHash); + if (!receipt || receipt.status !== "success") return null; + + // 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; + + return receipt; + } catch { + return null; + } +} diff --git a/lib/index-fetch.ts b/lib/index-fetch.ts new file mode 100644 index 00000000..d3287b2d --- /dev/null +++ b/lib/index-fetch.ts @@ -0,0 +1,13 @@ +/** + * 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. + */ + +export function indexFetch(route: string, body: Record): Promise { + return fetch(route, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} diff --git a/src/app/api/index/donation/route.ts b/src/app/api/index/donation/route.ts index e31b3c57..727c769c 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 { validateRecentTx } from "../../../../../lib/index-auth"; import { storyFactoryAbi, donationEvent, @@ -27,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 650ce342..5831166a 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 { validateRecentTx } from "../../../../../lib/index-auth"; import { storyFactoryAbi, plotChainedEvent, @@ -33,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 fd125e18..1fb9e3e2 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 { validateRecentTx } from "../../../../../lib/index-auth"; import { storyFactoryAbi, storylineCreatedEvent, @@ -38,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 91a8613f..5930bbf2 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 { validateRecentTx } from "../../../../../lib/index-auth"; import type { Database } from "../../../../../lib/supabase"; type TradeInsert = Database["public"]["Tables"]["trade_history"]["Insert"]; @@ -18,9 +19,13 @@ export async function POST(req: Request) { 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); @@ -33,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; 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) {