From 72ead26b923105c7d4150a6a00f3d1e32798546c Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 13 Mar 2026 19:44:17 +0000 Subject: [PATCH 1/4] [#9] Add inline plot indexer API route and content column migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement POST /api/index/plot — fetches tx receipt, decodes PlotChained event, fetches content from IPFS (10s timeout with fallback to request body), verifies keccak256 hash match, gets block timestamp, and upserts to Supabase. Add migration for content TEXT column on plots table (proposal §4.1 primary read path). Add Base public client helper. Fixes #9 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/supabase.ts | 3 + lib/viem.ts | 12 ++ src/app/api/index/plot/route.ts | 129 ++++++++++++++++++ .../migrations/00002_plots_content_column.sql | 4 + 4 files changed, 148 insertions(+) create mode 100644 lib/viem.ts create mode 100644 src/app/api/index/plot/route.ts create mode 100644 supabase/migrations/00002_plots_content_column.sql diff --git a/lib/supabase.ts b/lib/supabase.ts index 65d9b777..ff32ad32 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -96,6 +96,7 @@ export interface Database { storyline_id: number; plot_index: number; writer_address: string; + content: string | null; content_cid: string; content_hash: string; hidden: boolean; @@ -109,6 +110,7 @@ export interface Database { storyline_id: number; plot_index: number; writer_address: string; + content?: string | null; content_cid: string; content_hash: string; hidden?: boolean; @@ -122,6 +124,7 @@ export interface Database { storyline_id?: number; plot_index?: number; writer_address?: string; + content?: string | null; content_cid?: string; content_hash?: string; hidden?: boolean; diff --git a/lib/viem.ts b/lib/viem.ts new file mode 100644 index 00000000..3b68a30d --- /dev/null +++ b/lib/viem.ts @@ -0,0 +1,12 @@ +import { createPublicClient, http } from "viem"; +import { base } from "viem/chains"; + +/** + * Public client for reading from Base. + * + * Uses the default Base RPC. Override via NEXT_PUBLIC_BASE_RPC_URL env var. + */ +export const publicClient = createPublicClient({ + chain: base, + transport: http(process.env.NEXT_PUBLIC_BASE_RPC_URL || undefined), +}); diff --git a/src/app/api/index/plot/route.ts b/src/app/api/index/plot/route.ts new file mode 100644 index 00000000..a51cffc6 --- /dev/null +++ b/src/app/api/index/plot/route.ts @@ -0,0 +1,129 @@ +import { NextResponse } from "next/server"; +import { type Hex, decodeEventLog } from "viem"; +import { publicClient } from "../../../../../lib/viem"; +import { createServerClient } from "../../../../../lib/supabase"; +import { storyFactoryAbi } from "../../../../../lib/contracts/abi"; +import { STORY_FACTORY } from "../../../../../lib/contracts/constants"; +import { hashContent } from "../../../../../lib/content"; +import type { Database } from "../../../../../lib/supabase"; + +const IPFS_GATEWAY = "https://ipfs.filebase.io/ipfs/"; +const IPFS_TIMEOUT_MS = 10_000; + +function error(message: string, status = 400) { + return NextResponse.json({ error: message }, { status }); +} + +export async function POST(req: Request) { + const body = await req.json(); + const txHash = body.txHash as Hex | undefined; + const fallbackContent = body.content as string | undefined; + + if (!txHash) { + return error("Missing txHash"); + } + + // 1. Fetch receipt + let receipt; + try { + receipt = await publicClient.getTransactionReceipt({ hash: txHash }); + } catch { + return error("Failed to fetch transaction receipt", 502); + } + + if (receipt.status !== "success") { + return error("Transaction failed"); + } + + // 2. Find PlotChained event log from StoryFactory + const plotChainedLog = receipt.logs.find( + (log) => + log.address.toLowerCase() === STORY_FACTORY.toLowerCase() && + log.topics.length >= 4 + ); + + if (!plotChainedLog) { + return error("PlotChained event not found in receipt"); + } + + // 3. Decode event + let decoded; + try { + decoded = decodeEventLog({ + abi: storyFactoryAbi, + data: plotChainedLog.data, + topics: plotChainedLog.topics, + }); + } catch { + return error("Failed to decode PlotChained event"); + } + + if (decoded.eventName !== "PlotChained") { + return error("Unexpected event type"); + } + + const { storylineId, plotIndex, writer, contentCID, contentHash } = + decoded.args; + + // 4. Fetch content from IPFS (with fallback) + let content: string; + try { + const ipfsRes = await fetch(`${IPFS_GATEWAY}${contentCID}`, { + signal: AbortSignal.timeout(IPFS_TIMEOUT_MS), + }); + if (!ipfsRes.ok) throw new Error(`IPFS status ${ipfsRes.status}`); + content = await ipfsRes.text(); + } catch { + if (!fallbackContent) { + return error("IPFS fetch failed and no fallback content provided", 502); + } + content = fallbackContent; + } + + // 5. Verify content hash + const computedHash = hashContent(content); + if (computedHash !== contentHash) { + return error("Content hash mismatch"); + } + + // 6. Get block timestamp + let blockTimestamp: bigint; + try { + const block = await publicClient.getBlock({ + blockNumber: receipt.blockNumber, + }); + blockTimestamp = block.timestamp; + } catch { + return error("Failed to fetch block", 502); + } + + // 7. Upsert to Supabase + const supabase = createServerClient(); + if (!supabase) { + return error("Supabase not configured", 500); + } + + const row: Database["public"]["Tables"]["plots"]["Insert"] = { + storyline_id: Number(storylineId), + plot_index: Number(plotIndex), + writer_address: writer.toLowerCase(), + content, + content_cid: contentCID, + content_hash: contentHash as string, + block_timestamp: new Date(Number(blockTimestamp) * 1000).toISOString(), + tx_hash: txHash.toLowerCase(), + log_index: plotChainedLog.logIndex!, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error: dbError } = await (supabase.from("plots") as any).upsert( + row, + { onConflict: "tx_hash,log_index" } + ); + + if (dbError) { + return error(`Database error: ${dbError.message}`, 500); + } + + return NextResponse.json({ success: true }); +} diff --git a/supabase/migrations/00002_plots_content_column.sql b/supabase/migrations/00002_plots_content_column.sql new file mode 100644 index 00000000..9d079f1a --- /dev/null +++ b/supabase/migrations/00002_plots_content_column.sql @@ -0,0 +1,4 @@ +-- Add content column to plots table. +-- Proposal §4.1 requires Supabase as the primary read path for content. +-- The indexer fetches content from IPFS and stores it here. +alter table plots add column content text; From 2405e689777cc79848d43af8adfeb0948d40c528 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 13 Mar 2026 19:46:47 +0000 Subject: [PATCH 2/4] [#9] Match PlotChained log by event signature instead of address Fix: the previous implementation filtered logs by STORY_FACTORY address which is still a zero-address placeholder. Now matches by topic0 (event signature hash) computed from the ABI, which works regardless of the contract deployment address. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/index/plot/route.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/app/api/index/plot/route.ts b/src/app/api/index/plot/route.ts index a51cffc6..b87d9270 100644 --- a/src/app/api/index/plot/route.ts +++ b/src/app/api/index/plot/route.ts @@ -1,15 +1,23 @@ import { NextResponse } from "next/server"; -import { type Hex, decodeEventLog } from "viem"; +import { type Hex, decodeEventLog, encodeEventTopics } from "viem"; import { publicClient } from "../../../../../lib/viem"; import { createServerClient } from "../../../../../lib/supabase"; -import { storyFactoryAbi } from "../../../../../lib/contracts/abi"; -import { STORY_FACTORY } from "../../../../../lib/contracts/constants"; +import { + storyFactoryAbi, + plotChainedEvent, +} from "../../../../../lib/contracts/abi"; import { hashContent } from "../../../../../lib/content"; import type { Database } from "../../../../../lib/supabase"; const IPFS_GATEWAY = "https://ipfs.filebase.io/ipfs/"; const IPFS_TIMEOUT_MS = 10_000; +/** PlotChained event topic0 (keccak256 of the event signature) */ +const PLOT_CHAINED_TOPIC = encodeEventTopics({ + abi: [plotChainedEvent], + eventName: "PlotChained", +})[0]; + function error(message: string, status = 400) { return NextResponse.json({ error: message }, { status }); } @@ -35,11 +43,9 @@ export async function POST(req: Request) { return error("Transaction failed"); } - // 2. Find PlotChained event log from StoryFactory + // 2. Find PlotChained event log by event signature (topic0) const plotChainedLog = receipt.logs.find( - (log) => - log.address.toLowerCase() === STORY_FACTORY.toLowerCase() && - log.topics.length >= 4 + (log) => log.topics[0] === PLOT_CHAINED_TOPIC ); if (!plotChainedLog) { From abaf40857d54bf57963d282b75b78023da977f9f Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 13 Mar 2026 19:48:06 +0000 Subject: [PATCH 3/4] [#9] Address review feedback: input validation, migration idempotency, env var 1. Add txHash regex validation (0x + 64 hex chars) on the public endpoint 2. Use IF NOT EXISTS in migration for idempotency 3. Rename NEXT_PUBLIC_BASE_RPC_URL to BASE_RPC_URL (server-only) Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/viem.ts | 2 +- src/app/api/index/plot/route.ts | 4 ++-- supabase/migrations/00002_plots_content_column.sql | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/viem.ts b/lib/viem.ts index 3b68a30d..4579c301 100644 --- a/lib/viem.ts +++ b/lib/viem.ts @@ -8,5 +8,5 @@ import { base } from "viem/chains"; */ export const publicClient = createPublicClient({ chain: base, - transport: http(process.env.NEXT_PUBLIC_BASE_RPC_URL || undefined), + transport: http(process.env.BASE_RPC_URL || undefined), }); diff --git a/src/app/api/index/plot/route.ts b/src/app/api/index/plot/route.ts index b87d9270..7a12dfbe 100644 --- a/src/app/api/index/plot/route.ts +++ b/src/app/api/index/plot/route.ts @@ -27,8 +27,8 @@ export async function POST(req: Request) { const txHash = body.txHash as Hex | undefined; const fallbackContent = body.content as string | undefined; - if (!txHash) { - return error("Missing txHash"); + if (!txHash || !/^0x[0-9a-fA-F]{64}$/.test(txHash)) { + return error("Missing or invalid txHash"); } // 1. Fetch receipt diff --git a/supabase/migrations/00002_plots_content_column.sql b/supabase/migrations/00002_plots_content_column.sql index 9d079f1a..04fe9b93 100644 --- a/supabase/migrations/00002_plots_content_column.sql +++ b/supabase/migrations/00002_plots_content_column.sql @@ -1,4 +1,4 @@ -- Add content column to plots table. -- Proposal §4.1 requires Supabase as the primary read path for content. -- The indexer fetches content from IPFS and stores it here. -alter table plots add column content text; +alter table plots add column if not exists content text; From 20cdd15f2995b84613115e65299a0fb05b4afed4 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 13 Mar 2026 19:49:12 +0000 Subject: [PATCH 4/4] [#9] Fix JSDoc reference to renamed env var Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/viem.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/viem.ts b/lib/viem.ts index 4579c301..cfb41c1c 100644 --- a/lib/viem.ts +++ b/lib/viem.ts @@ -4,7 +4,7 @@ import { base } from "viem/chains"; /** * Public client for reading from Base. * - * Uses the default Base RPC. Override via NEXT_PUBLIC_BASE_RPC_URL env var. + * Uses the default Base RPC. Override via BASE_RPC_URL env var. */ export const publicClient = createPublicClient({ chain: base,