From b106e15a4e8eacf56b90656fe21e3f67d6152423 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 20 Mar 2026 20:14:55 +0000 Subject: [PATCH 1/2] [#391] Batch RPC calls with multicall on home page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add getBatchTokenData() to lib/price.ts — fetches price + TVL for all tokens in a single multicall (3 calls per token → 1 RPC request). Home page now uses StoryGrid → BatchTokenDataProvider context. StoryCardTVL consumes batch data when available, falls back to individual fetch on detail pages. Reduces ~32 simultaneous RPC calls to 1 multicall request. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/price.ts | 90 +++++++++++++++++++++++ src/app/page.tsx | 10 +-- src/components/BatchTokenDataProvider.tsx | 40 ++++++++++ src/components/StoryCardStats.tsx | 10 ++- src/components/StoryGrid.tsx | 27 +++++++ 5 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 src/components/BatchTokenDataProvider.tsx create mode 100644 src/components/StoryGrid.tsx diff --git a/lib/price.ts b/lib/price.ts index bacb31ea..4f88dbf7 100644 --- a/lib/price.ts +++ b/lib/price.ts @@ -264,3 +264,93 @@ export async function getTokenTVL( return null; } } + +// --------------------------------------------------------------------------- +// Batched multicall for multiple tokens (home page) +// --------------------------------------------------------------------------- + +export interface BatchTokenEntry { + price: TokenPriceInfo | null; + tvl: { tvl: string; tvlRaw: bigint; reserveToken: Address; decimals: number } | null; +} + +/** + * Fetch price + TVL for multiple tokens in a single multicall RPC request. + * Returns a Map keyed by lowercase token address. + * + * Each token produces 3 calls: priceForNextMint, totalSupply, tokenBond. + */ +export async function getBatchTokenData( + tokenAddresses: Address[], +): Promise> { + const result = new Map(); + if (tokenAddresses.length === 0) return result; + + const calls = tokenAddresses.flatMap((token) => [ + { + address: MCV2_BOND as Address, + abi: [priceForNextMintFunction], + functionName: "priceForNextMint" as const, + args: [token] as const, + }, + { + address: token, + abi: erc20Abi, + functionName: "totalSupply" as const, + }, + { + address: MCV2_BOND as Address, + abi: [tokenBondFunction], + functionName: "tokenBond" as const, + args: [token] as const, + }, + ]); + + try { + const multicallResults = await publicClient.multicall({ + contracts: calls, + allowFailure: true, + }); + + // Parse results in groups of 3 per token + for (let i = 0; i < tokenAddresses.length; i++) { + const addr = tokenAddresses[i].toLowerCase(); + const base = i * 3; + const priceResult = multicallResults[base]; + const supplyResult = multicallResults[base + 1]; + const bondResult = multicallResults[base + 2]; + + let price: TokenPriceInfo | null = null; + if (priceResult.status === "success" && supplyResult.status === "success") { + const priceRaw = priceResult.result as bigint; + const totalSupplyRaw = supplyResult.result as bigint; + price = { + pricePerToken: formatUnits(priceRaw, 18), + priceRaw, + totalSupply: formatUnits(totalSupplyRaw, 18), + totalSupplyRaw, + }; + } + + let tvl: BatchTokenEntry["tvl"] = null; + if (bondResult.status === "success") { + const bondData = bondResult.result as readonly unknown[]; + const reserveToken = bondData[4] as Address; + const reserveBalance = bondData[5] as bigint; + // Default to 18 decimals (PL_TEST/WETH) — avoids extra RPC call + tvl = { + tvl: formatUnits(reserveBalance, 18), + tvlRaw: reserveBalance, + reserveToken, + decimals: 18, + }; + } + + result.set(addr, { price, tvl }); + } + } catch { + // If multicall fails entirely, return empty map (callers fall back) + } + + return result; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index f087dfee..f4a697ed 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,7 @@ import { createServerClient, type Storyline } from "../../lib/supabase"; import { STORY_FACTORY } from "../../lib/contracts/constants"; import { getTrendingStorylines } from "../../lib/ranking"; -import { StoryCard } from "../components/StoryCard"; +import { StoryGrid } from "../components/StoryGrid"; import { FilterBar, type WriterFilterValue } from "../components/FilterBar"; import { GENRES, LANGUAGES } from "../../lib/genres"; import Link from "next/link"; @@ -55,12 +55,8 @@ export default async function Home({ {/* Filter bar */} - {/* Story grid */} -
- {storylines.map((s) => ( - - ))} -
+ {/* Story grid — batched multicall for price/TVL */} + {/* Pagination */} {(page > 1 || storylines.length === PAGE_SIZE) && ( diff --git a/src/components/BatchTokenDataProvider.tsx b/src/components/BatchTokenDataProvider.tsx new file mode 100644 index 00000000..c1e61cef --- /dev/null +++ b/src/components/BatchTokenDataProvider.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { createContext, useContext, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { type Address } from "viem"; +import { getBatchTokenData, type BatchTokenEntry } from "../../lib/price"; + +type BatchTokenDataMap = Map; + +const BatchTokenDataContext = createContext(new Map()); + +export function useBatchTokenData(tokenAddress: string): BatchTokenEntry | undefined { + const map = useContext(BatchTokenDataContext); + return map.get(tokenAddress.toLowerCase()); +} + +/** + * Fetches price + TVL for all provided token addresses in a single + * multicall RPC request and provides the data via context. + */ +export function BatchTokenDataProvider({ + tokenAddresses, + children, +}: { + tokenAddresses: Address[]; + children: ReactNode; +}) { + const { data } = useQuery({ + queryKey: ["batch-token-data", tokenAddresses.join(",")], + queryFn: () => getBatchTokenData(tokenAddresses), + staleTime: 60000, + enabled: tokenAddresses.length > 0, + }); + + return ( + + {children} + + ); +} diff --git a/src/components/StoryCardStats.tsx b/src/components/StoryCardStats.tsx index 98b9059b..885667c2 100644 --- a/src/components/StoryCardStats.tsx +++ b/src/components/StoryCardStats.tsx @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { type Address } from "viem"; import { getTokenTVL, getTokenPrice } from "../../lib/price"; import { RESERVE_LABEL } from "../../lib/contracts/constants"; +import { useBatchTokenData } from "./BatchTokenDataProvider"; function formatCompact(value: string): string { const num = parseFloat(value); @@ -45,16 +46,21 @@ export function StoryCardStats({ tokenAddress }: { tokenAddress: string }) { ); } -/** TVL-only display for home page book cards */ +/** TVL-only display for home page book cards. + * Uses batch context when available (home page), falls back to individual fetch. */ export function StoryCardTVL({ tokenAddress }: { tokenAddress: string }) { + const batchEntry = useBatchTokenData(tokenAddress); const addr = tokenAddress as Address; - const { data: tvlData } = useQuery({ + // Fall back to individual fetch only if batch data not available + const { data: individualTvl } = useQuery({ queryKey: ["card-tvl", tokenAddress], queryFn: () => getTokenTVL(addr), staleTime: 60000, + enabled: !batchEntry, }); + const tvlData = batchEntry?.tvl ?? individualTvl; const tvl = tvlData ? formatCompact(tvlData.tvl) : "—"; return ( diff --git a/src/components/StoryGrid.tsx b/src/components/StoryGrid.tsx new file mode 100644 index 00000000..11efa088 --- /dev/null +++ b/src/components/StoryGrid.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { type Address } from "viem"; +import { type Storyline } from "../../lib/supabase"; +import { BatchTokenDataProvider } from "./BatchTokenDataProvider"; +import { StoryCard } from "./StoryCard"; + +/** + * Story card grid wrapped in BatchTokenDataProvider. + * Fetches price + TVL for all visible stories in a single multicall + * instead of 4 individual RPC calls per card. + */ +export function StoryGrid({ storylines }: { storylines: Storyline[] }) { + const tokenAddresses = storylines + .map((s) => s.token_address) + .filter((addr): addr is string => !!addr) as Address[]; + + return ( + +
+ {storylines.map((s) => ( + + ))} +
+
+ ); +} From 99e10007d80b2eea96109aa196687d257910a1ad Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 20 Mar 2026 20:17:47 +0000 Subject: [PATCH 2/2] [#391] Use browserClient for batch multicall in client components getBatchTokenData() now accepts optional client parameter. BatchTokenDataProvider passes browserClient (CORS-safe) instead of server publicClient. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/price.ts | 4 +++- src/components/BatchTokenDataProvider.tsx | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/price.ts b/lib/price.ts index 4f88dbf7..0277d6d6 100644 --- a/lib/price.ts +++ b/lib/price.ts @@ -282,7 +282,9 @@ export interface BatchTokenEntry { */ export async function getBatchTokenData( tokenAddresses: Address[], + client?: typeof publicClient, ): Promise> { + const rpcClient = client ?? publicClient; const result = new Map(); if (tokenAddresses.length === 0) return result; @@ -307,7 +309,7 @@ export async function getBatchTokenData( ]); try { - const multicallResults = await publicClient.multicall({ + const multicallResults = await rpcClient.multicall({ contracts: calls, allowFailure: true, }); diff --git a/src/components/BatchTokenDataProvider.tsx b/src/components/BatchTokenDataProvider.tsx index c1e61cef..f79b5d0c 100644 --- a/src/components/BatchTokenDataProvider.tsx +++ b/src/components/BatchTokenDataProvider.tsx @@ -4,6 +4,7 @@ import { createContext, useContext, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { type Address } from "viem"; import { getBatchTokenData, type BatchTokenEntry } from "../../lib/price"; +import { browserClient } from "../../lib/rpc"; type BatchTokenDataMap = Map; @@ -27,7 +28,7 @@ export function BatchTokenDataProvider({ }) { const { data } = useQuery({ queryKey: ["batch-token-data", tokenAddresses.join(",")], - queryFn: () => getBatchTokenData(tokenAddresses), + queryFn: () => getBatchTokenData(tokenAddresses, browserClient), staleTime: 60000, enabled: tokenAddresses.length > 0, });