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
33 changes: 33 additions & 0 deletions lib/index-auth.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
13 changes: 13 additions & 0 deletions lib/index-fetch.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Promise<Response> {
return fetch(route, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
15 changes: 5 additions & 10 deletions src/app/api/index/donation/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server";
import { type Hex, decodeEventLog, encodeEventTopics } from "viem";
import { publicClient, getReceiptWithRetry } from "../../../../../lib/rpc";

Check warning on line 3 in src/app/api/index/donation/route.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'getReceiptWithRetry' is defined but never used
import { createServerClient } from "../../../../../lib/supabase";
import { validateRecentTx } from "../../../../../lib/index-auth";
import {
storyFactoryAbi,
donationEvent,
Expand All @@ -27,16 +28,10 @@
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)
Expand Down
15 changes: 5 additions & 10 deletions src/app/api/index/plot/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server";
import { type Hex, decodeEventLog, encodeEventTopics } from "viem";
import { publicClient, getReceiptWithRetry } from "../../../../../lib/rpc";

Check warning on line 3 in src/app/api/index/plot/route.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'getReceiptWithRetry' is defined but never used
import { createServerClient } from "../../../../../lib/supabase";
import { validateRecentTx } from "../../../../../lib/index-auth";
import {
storyFactoryAbi,
plotChainedEvent,
Expand Down Expand Up @@ -33,16 +34,10 @@
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)
Expand Down
15 changes: 5 additions & 10 deletions src/app/api/index/storyline/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server";
import { type Hex, decodeEventLog, encodeEventTopics } from "viem";
import { publicClient, getReceiptWithRetry } from "../../../../../lib/rpc";

Check warning on line 3 in src/app/api/index/storyline/route.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'getReceiptWithRetry' is defined but never used
import { createServerClient } from "../../../../../lib/supabase";
import { validateRecentTx } from "../../../../../lib/index-auth";
import {
storyFactoryAbi,
storylineCreatedEvent,
Expand Down Expand Up @@ -38,16 +39,10 @@
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)
Expand Down
11 changes: 8 additions & 3 deletions src/app/api/index/trade/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { NextResponse } from "next/server";
import { type Hex, decodeEventLog, formatUnits } from "viem";
import { publicClient, getReceiptWithRetry } from "../../../../../lib/rpc";

Check warning on line 3 in src/app/api/index/trade/route.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'getReceiptWithRetry' is defined but never used
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"];
Expand All @@ -18,9 +19,13 @@
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);

Expand All @@ -33,8 +38,8 @@

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;
Expand Down
7 changes: 2 additions & 5 deletions src/components/DonateWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.");
}
Expand Down
7 changes: 2 additions & 5 deletions src/components/TradingWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
7 changes: 2 additions & 5 deletions src/hooks/usePublish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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) {
Expand Down
Loading