diff --git a/lib/price.ts b/lib/price.ts index bacb31ea..0277d6d6 100644 --- a/lib/price.ts +++ b/lib/price.ts @@ -264,3 +264,95 @@ 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[], + client?: typeof publicClient, +): Promise> { + const rpcClient = client ?? publicClient; + 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 rpcClient.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..f79b5d0c --- /dev/null +++ b/src/components/BatchTokenDataProvider.tsx @@ -0,0 +1,41 @@ +"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"; +import { browserClient } from "../../lib/rpc"; + +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, browserClient), + 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) => ( + + ))} +
+
+ ); +}