From be525b0fad9b6c4a03d915ad59dab2d578a75abb Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 13 Mar 2026 20:28:07 +0000 Subject: [PATCH 1/2] [#52] Fix StorylineCreated ABI and add genesis plot indexing 1. Add missing openingCID (string) and openingHash (bytes32) fields to StorylineCreated event ABI 2. Update storyline indexer to insert genesis plot (plot_index=0) into plots table with IPFS content fetch, hash verification, and fallback to request body content Without this fix, opening chapters were invisible to readers because the storyline indexer only created the storylines row but never stored the genesis plot content. Fixes #52 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/contracts/abi.ts | 2 + src/app/api/index/storyline/route.ts | 75 +++++++++++++++++++++++++--- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/lib/contracts/abi.ts b/lib/contracts/abi.ts index 5acfc481..5475fd27 100644 --- a/lib/contracts/abi.ts +++ b/lib/contracts/abi.ts @@ -30,6 +30,8 @@ export const storylineCreatedEvent = { { name: "tokenAddress", type: "address", indexed: false }, { name: "title", type: "string", indexed: false }, { name: "hasDeadline", type: "bool", indexed: false }, + { name: "openingCID", type: "string", indexed: false }, + { name: "openingHash", type: "bytes32", indexed: false }, ], } as const; diff --git a/src/app/api/index/storyline/route.ts b/src/app/api/index/storyline/route.ts index 1de941f9..32964a05 100644 --- a/src/app/api/index/storyline/route.ts +++ b/src/app/api/index/storyline/route.ts @@ -7,8 +7,12 @@ import { storylineCreatedEvent, } from "../../../../../lib/contracts/abi"; import { detectWriterType } from "../../../../../lib/contracts/erc8004"; +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; + /** StorylineCreated event topic0 */ const STORYLINE_CREATED_TOPIC = encodeEventTopics({ abi: [storylineCreatedEvent], @@ -22,6 +26,7 @@ function error(message: string, status = 400) { 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 || !/^0x[0-9a-fA-F]{64}$/.test(txHash)) { return error("Missing or invalid txHash"); @@ -64,8 +69,15 @@ export async function POST(req: Request) { return error("Unexpected event type"); } - const { storylineId, writer, tokenAddress, title, hasDeadline } = - decoded.args; + const { + storylineId, + writer, + tokenAddress, + title, + hasDeadline, + openingCID, + openingHash, + } = decoded.args; // 4. Get block timestamp let blockTimestamp: bigint; @@ -81,13 +93,37 @@ export async function POST(req: Request) { // 5. Detect writer type via ERC-8004 (best-effort, defaults to human) const writerType = await detectWriterType(writer); - // 6. Upsert to Supabase + // 6. Fetch genesis plot content from IPFS (with fallback) + let genesisContent: string | null = null; + try { + const ipfsRes = await fetch(`${IPFS_GATEWAY}${openingCID}`, { + signal: AbortSignal.timeout(IPFS_TIMEOUT_MS), + }); + if (!ipfsRes.ok) throw new Error(`IPFS status ${ipfsRes.status}`); + genesisContent = await ipfsRes.text(); + } catch { + if (fallbackContent) { + genesisContent = fallbackContent; + } + } + + // 7. Verify genesis content hash (if content was fetched) + if (genesisContent !== null) { + const computedHash = hashContent(genesisContent); + if (computedHash !== openingHash) { + genesisContent = null; // reject tampered content + } + } + + // 8. Upsert storyline to Supabase const supabase = createServerClient(); if (!supabase) { return error("Supabase not configured", 500); } - const row: Database["public"]["Tables"]["storylines"]["Insert"] = { + const timestampISO = new Date(Number(blockTimestamp) * 1000).toISOString(); + + const storylineRow: Database["public"]["Tables"]["storylines"]["Insert"] = { storyline_id: Number(storylineId), writer_address: writer.toLowerCase(), token_address: tokenAddress.toLowerCase(), @@ -95,20 +131,43 @@ export async function POST(req: Request) { plot_count: 1, // genesis plot has_deadline: hasDeadline, writer_type: writerType, - last_plot_time: new Date(Number(blockTimestamp) * 1000).toISOString(), - block_timestamp: new Date(Number(blockTimestamp) * 1000).toISOString(), + last_plot_time: timestampISO, + block_timestamp: timestampISO, tx_hash: txHash.toLowerCase(), log_index: storylineLog.logIndex!, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const { error: dbError } = await (supabase.from("storylines") as any).upsert( - row, + storylineRow, { onConflict: "tx_hash,log_index" } ); if (dbError) { - return error(`Database error: ${dbError.message}`, 500); + return error(`Database error (storyline): ${dbError.message}`, 500); + } + + // 9. Insert genesis plot (plot_index = 0) into plots table + const plotRow: Database["public"]["Tables"]["plots"]["Insert"] = { + storyline_id: Number(storylineId), + plot_index: 0, + writer_address: writer.toLowerCase(), + content: genesisContent, + content_cid: openingCID, + content_hash: openingHash as string, + block_timestamp: timestampISO, + tx_hash: txHash.toLowerCase(), + log_index: storylineLog.logIndex!, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error: plotDbError } = await (supabase.from("plots") as any).upsert( + plotRow, + { onConflict: "tx_hash,log_index" } + ); + + if (plotDbError) { + return error(`Database error (genesis plot): ${plotDbError.message}`, 500); } return NextResponse.json({ success: true }); From 4b25081221aa40f6d843a52d8f5c7f2cb5df864c Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 13 Mar 2026 20:29:52 +0000 Subject: [PATCH 2/2] [#52] Fail request when genesis content cannot be verified Mirror plot indexer behavior: return error if IPFS fetch fails without fallback, or if content hash doesn't match openingHash. Never insert a plots row with null content. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/index/storyline/route.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/app/api/index/storyline/route.ts b/src/app/api/index/storyline/route.ts index 32964a05..8aec030b 100644 --- a/src/app/api/index/storyline/route.ts +++ b/src/app/api/index/storyline/route.ts @@ -94,7 +94,7 @@ export async function POST(req: Request) { const writerType = await detectWriterType(writer); // 6. Fetch genesis plot content from IPFS (with fallback) - let genesisContent: string | null = null; + let genesisContent: string; try { const ipfsRes = await fetch(`${IPFS_GATEWAY}${openingCID}`, { signal: AbortSignal.timeout(IPFS_TIMEOUT_MS), @@ -102,17 +102,19 @@ export async function POST(req: Request) { if (!ipfsRes.ok) throw new Error(`IPFS status ${ipfsRes.status}`); genesisContent = await ipfsRes.text(); } catch { - if (fallbackContent) { - genesisContent = fallbackContent; + if (!fallbackContent) { + return error( + "IPFS fetch failed and no fallback content provided", + 502 + ); } + genesisContent = fallbackContent; } - // 7. Verify genesis content hash (if content was fetched) - if (genesisContent !== null) { - const computedHash = hashContent(genesisContent); - if (computedHash !== openingHash) { - genesisContent = null; // reject tampered content - } + // 7. Verify genesis content hash + const computedHash = hashContent(genesisContent); + if (computedHash !== openingHash) { + return error("Genesis content hash mismatch"); } // 8. Upsert storyline to Supabase