From f0f2115a52f1516ecd3c1e6c39cd65d5d6e148f3 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 20 Mar 2026 20:31:23 +0000 Subject: [PATCH 1/2] [#393] Fix RPC race condition + route browser calls through browserClient 1. BatchTokenDataProvider now exposes isReady flag. StoryCardTVL waits for batch to settle before enabling individual fallback, preventing race where individual calls fire before batch arrives. 2. getTokenPrice/getTokenTVL/get24hPriceChange accept optional client parameter. All browser components pass browserClient for CORS-safe RPC access. Server-side callers unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/price.ts | 20 +++++++++++++------- src/app/dashboard/writer/page.tsx | 3 ++- src/components/BatchTokenDataProvider.tsx | 23 +++++++++++++++++------ src/components/ClaimRoyalties.tsx | 3 ++- src/components/ReaderPortfolio.tsx | 5 +++-- src/components/StoryCardStats.tsx | 13 +++++++------ src/components/WriterTradingStats.tsx | 3 ++- 7 files changed, 46 insertions(+), 24 deletions(-) diff --git a/lib/price.ts b/lib/price.ts index 0277d6d6..3f644a86 100644 --- a/lib/price.ts +++ b/lib/price.ts @@ -143,16 +143,18 @@ export interface TokenPriceInfo { */ export async function getTokenPrice( tokenAddress: Address, + client?: typeof publicClient, ): Promise { + const rpc = client ?? publicClient; try { const [priceRaw, totalSupplyRaw] = await Promise.all([ - publicClient.readContract({ + rpc.readContract({ address: MCV2_BOND, abi: mcv2BondAbi, functionName: "priceForNextMint", args: [tokenAddress], }), - publicClient.readContract({ + rpc.readContract({ address: tokenAddress, abi: erc20Abi, functionName: "totalSupply", @@ -181,19 +183,21 @@ const BLOCKS_PER_24H = BigInt(43200); */ export async function get24hPriceChange( tokenAddress: Address, + client?: typeof publicClient, ): Promise<{ changePercent: number; currentPrice: bigint; previousPrice: bigint } | null> { + const rpc = client ?? publicClient; try { - const currentBlock = await publicClient.getBlockNumber(); + const currentBlock = await rpc.getBlockNumber(); const pastBlock = currentBlock - BLOCKS_PER_24H; const [currentPrice, previousPrice] = await Promise.all([ - publicClient.readContract({ + rpc.readContract({ address: MCV2_BOND, abi: mcv2BondAbi, functionName: "priceForNextMint", args: [tokenAddress], }), - publicClient.readContract({ + rpc.readContract({ address: MCV2_BOND, abi: mcv2BondAbi, functionName: "priceForNextMint", @@ -236,9 +240,11 @@ const erc20DecimalsAbi = [ */ export async function getTokenTVL( tokenAddress: Address, + client?: typeof publicClient, ): Promise<{ tvl: string; tvlRaw: bigint; reserveToken: Address; decimals: number } | null> { + const rpc = client ?? publicClient; try { - const result = await publicClient.readContract({ + const result = await rpc.readContract({ address: MCV2_BOND, abi: mcv2BondAbi, functionName: "tokenBond", @@ -248,7 +254,7 @@ export async function getTokenTVL( const [, , , , reserveToken, reserveBalance] = result; const reserveAddr = reserveToken as Address; - const decimals = await publicClient.readContract({ + const decimals = await rpc.readContract({ address: reserveAddr, abi: erc20DecimalsAbi, functionName: "decimals", diff --git a/src/app/dashboard/writer/page.tsx b/src/app/dashboard/writer/page.tsx index 201ddfda..3d9913a8 100644 --- a/src/app/dashboard/writer/page.tsx +++ b/src/app/dashboard/writer/page.tsx @@ -6,6 +6,7 @@ import { useQuery, useQueryClient, useInfiniteQuery } from "@tanstack/react-quer import { formatUnits } from "viem"; import { supabase, type Storyline, type Donation } from "../../../../lib/supabase"; import { getTokenTVL } from "../../../../lib/price"; +import { browserClient } from "../../../../lib/rpc"; import { RESERVE_LABEL, STORY_FACTORY, EXPLORER_URL } from "../../../../lib/contracts/constants"; import { GENRES, LANGUAGES } from "../../../../lib/genres"; import { DeadlineCountdown } from "../../../components/DeadlineCountdown"; @@ -370,7 +371,7 @@ function DonationCount({ storylineId, tokenAddress }: { storylineId: number; tok queryKey: ["donation-count", storylineId, tokenAddress], queryFn: async () => { const [tvlData, rows] = await Promise.all([ - getTokenTVL(tokenAddress as Address), + getTokenTVL(tokenAddress as Address, browserClient), supabase ? supabase.from("donations") .select("amount") diff --git a/src/components/BatchTokenDataProvider.tsx b/src/components/BatchTokenDataProvider.tsx index f79b5d0c..893637b1 100644 --- a/src/components/BatchTokenDataProvider.tsx +++ b/src/components/BatchTokenDataProvider.tsx @@ -8,11 +8,22 @@ import { browserClient } from "../../lib/rpc"; type BatchTokenDataMap = Map; -const BatchTokenDataContext = createContext(new Map()); +interface BatchTokenDataContextValue { + data: BatchTokenDataMap; + isReady: boolean; +} + +const BatchTokenDataContext = createContext({ + data: new Map(), + isReady: false, +}); -export function useBatchTokenData(tokenAddress: string): BatchTokenEntry | undefined { - const map = useContext(BatchTokenDataContext); - return map.get(tokenAddress.toLowerCase()); +export function useBatchTokenData(tokenAddress: string): { + entry: BatchTokenEntry | undefined; + isReady: boolean; +} { + const { data, isReady } = useContext(BatchTokenDataContext); + return { entry: data.get(tokenAddress.toLowerCase()), isReady }; } /** @@ -26,7 +37,7 @@ export function BatchTokenDataProvider({ tokenAddresses: Address[]; children: ReactNode; }) { - const { data } = useQuery({ + const { data, isLoading } = useQuery({ queryKey: ["batch-token-data", tokenAddresses.join(",")], queryFn: () => getBatchTokenData(tokenAddresses, browserClient), staleTime: 60000, @@ -34,7 +45,7 @@ export function BatchTokenDataProvider({ }); return ( - + {children} ); diff --git a/src/components/ClaimRoyalties.tsx b/src/components/ClaimRoyalties.tsx index f2a79381..16eaf317 100644 --- a/src/components/ClaimRoyalties.tsx +++ b/src/components/ClaimRoyalties.tsx @@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/react-query"; import { formatUnits, type Address } from "viem"; import { browserClient as publicClient } from "../../lib/rpc"; import { mcv2BondAbi, getTokenTVL } from "../../lib/price"; +import { browserClient } from "../../lib/rpc"; import { MCV2_BOND, RESERVE_LABEL, EXPLORER_URL, PLOT_TOKEN } from "../../lib/contracts/constants"; function formatTruncated(value: bigint, decimals: number, digits = 10): string { @@ -51,7 +52,7 @@ export function ClaimRoyalties({ tokenAddress, plotCount, beneficiary }: ClaimRo // Fetch reserve token decimals dynamically const { data: tvlData } = useQuery({ queryKey: ["claim-decimals", tokenAddress], - queryFn: () => getTokenTVL(tokenAddress), + queryFn: () => getTokenTVL(tokenAddress, browserClient), }); const decimals = tvlData?.decimals ?? 18; diff --git a/src/components/ReaderPortfolio.tsx b/src/components/ReaderPortfolio.tsx index 9c3616a7..12dc4ddd 100644 --- a/src/components/ReaderPortfolio.tsx +++ b/src/components/ReaderPortfolio.tsx @@ -6,6 +6,7 @@ import { formatUnits, type Address } from "viem"; import { formatPrice, formatSupply } from "../../lib/format"; import { browserClient as publicClient } from "../../lib/rpc"; import { erc20Abi, mcv2BondAbi, get24hPriceChange, getTokenTVL } from "../../lib/price"; +import { browserClient } from "../../lib/rpc"; import { MCV2_BOND, RESERVE_LABEL, STORY_FACTORY } from "../../lib/contracts/constants"; import { supabase, type Storyline } from "../../lib/supabase"; import Link from "next/link"; @@ -67,8 +68,8 @@ export function ReaderPortfolio() { functionName: "priceForNextMint", args: [tokenAddr], }), - get24hPriceChange(tokenAddr).catch(() => null), - getTokenTVL(tokenAddr).catch(() => null), + get24hPriceChange(tokenAddr, browserClient).catch(() => null), + getTokenTVL(tokenAddr, browserClient).catch(() => null), ]); const priceBI = BigInt(price); diff --git a/src/components/StoryCardStats.tsx b/src/components/StoryCardStats.tsx index 885667c2..1c652c86 100644 --- a/src/components/StoryCardStats.tsx +++ b/src/components/StoryCardStats.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { type Address } from "viem"; import { getTokenTVL, getTokenPrice } from "../../lib/price"; +import { browserClient } from "../../lib/rpc"; import { RESERVE_LABEL } from "../../lib/contracts/constants"; import { useBatchTokenData } from "./BatchTokenDataProvider"; @@ -21,13 +22,13 @@ export function StoryCardStats({ tokenAddress }: { tokenAddress: string }) { const { data: priceInfo } = useQuery({ queryKey: ["card-price", tokenAddress], - queryFn: () => getTokenPrice(addr), + queryFn: () => getTokenPrice(addr, browserClient), staleTime: 60000, }); const { data: tvlData } = useQuery({ queryKey: ["card-tvl", tokenAddress], - queryFn: () => getTokenTVL(addr), + queryFn: () => getTokenTVL(addr, browserClient), staleTime: 60000, }); @@ -49,15 +50,15 @@ export function StoryCardStats({ tokenAddress }: { tokenAddress: string }) { /** 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 { entry: batchEntry, isReady } = useBatchTokenData(tokenAddress); const addr = tokenAddress as Address; - // Fall back to individual fetch only if batch data not available + // Only fall back to individual fetch AFTER batch has settled const { data: individualTvl } = useQuery({ queryKey: ["card-tvl", tokenAddress], - queryFn: () => getTokenTVL(addr), + queryFn: () => getTokenTVL(addr, browserClient), staleTime: 60000, - enabled: !batchEntry, + enabled: isReady && !batchEntry, }); const tvlData = batchEntry?.tvl ?? individualTvl; diff --git a/src/components/WriterTradingStats.tsx b/src/components/WriterTradingStats.tsx index 7421548b..c4f3e0df 100644 --- a/src/components/WriterTradingStats.tsx +++ b/src/components/WriterTradingStats.tsx @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { formatUnits, type Address } from "viem"; import { browserClient as publicClient } from "../../lib/rpc"; import { mcv2BondAbi, getTokenTVL } from "../../lib/price"; +import { browserClient } from "../../lib/rpc"; import { MCV2_BOND, RESERVE_LABEL } from "../../lib/contracts/constants"; import { formatPrice } from "../../lib/format"; import type { Storyline } from "../../lib/supabase"; @@ -26,7 +27,7 @@ export function WriterTradingStats({ storyline }: WriterTradingStatsProps) { functionName: "priceForNextMint", args: [tokenAddress], }), - getTokenTVL(tokenAddress), + getTokenTVL(tokenAddress, browserClient), ]); const decimals = tvlData?.decimals ?? 18; return { From 220bc78567400b412e4fbd4256f1ee9fce519d0a Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 20 Mar 2026 20:33:54 +0000 Subject: [PATCH 2/2] [#393] Merge duplicate browserClient imports Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/ClaimRoyalties.tsx | 7 +++---- src/components/ReaderPortfolio.tsx | 7 +++---- src/components/WriterTradingStats.tsx | 5 ++--- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/components/ClaimRoyalties.tsx b/src/components/ClaimRoyalties.tsx index 16eaf317..3dd23ffb 100644 --- a/src/components/ClaimRoyalties.tsx +++ b/src/components/ClaimRoyalties.tsx @@ -4,9 +4,8 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { useWriteContract } from "wagmi"; import { useQuery } from "@tanstack/react-query"; import { formatUnits, type Address } from "viem"; -import { browserClient as publicClient } from "../../lib/rpc"; -import { mcv2BondAbi, getTokenTVL } from "../../lib/price"; import { browserClient } from "../../lib/rpc"; +import { mcv2BondAbi, getTokenTVL } from "../../lib/price"; import { MCV2_BOND, RESERVE_LABEL, EXPLORER_URL, PLOT_TOKEN } from "../../lib/contracts/constants"; function formatTruncated(value: bigint, decimals: number, digits = 10): string { @@ -38,7 +37,7 @@ export function ClaimRoyalties({ tokenAddress, plotCount, beneficiary }: ClaimRo const { data: royaltyInfo } = useQuery({ queryKey: ["royalty-info", tokenAddress, beneficiary], queryFn: async () => { - const [balance, claimed] = await publicClient.readContract({ + const [balance, claimed] = await browserClient.readContract({ address: MCV2_BOND, abi: mcv2BondAbi, functionName: "getRoyaltyInfo", @@ -93,7 +92,7 @@ export function ClaimRoyalties({ tokenAddress, plotCount, beneficiary }: ClaimRo setTxHash(hash); setTxState("pending"); - await publicClient.waitForTransactionReceipt({ hash }); + await browserClient.waitForTransactionReceipt({ hash }); setTxState("done"); } catch (err) { diff --git a/src/components/ReaderPortfolio.tsx b/src/components/ReaderPortfolio.tsx index 12dc4ddd..f891c05b 100644 --- a/src/components/ReaderPortfolio.tsx +++ b/src/components/ReaderPortfolio.tsx @@ -4,9 +4,8 @@ import { useAccount } from "wagmi"; import { useQuery } from "@tanstack/react-query"; import { formatUnits, type Address } from "viem"; import { formatPrice, formatSupply } from "../../lib/format"; -import { browserClient as publicClient } from "../../lib/rpc"; -import { erc20Abi, mcv2BondAbi, get24hPriceChange, getTokenTVL } from "../../lib/price"; import { browserClient } from "../../lib/rpc"; +import { erc20Abi, mcv2BondAbi, get24hPriceChange, getTokenTVL } from "../../lib/price"; import { MCV2_BOND, RESERVE_LABEL, STORY_FACTORY } from "../../lib/contracts/constants"; import { supabase, type Storyline } from "../../lib/supabase"; import Link from "next/link"; @@ -46,7 +45,7 @@ export function ReaderPortfolio() { args: [address], })); - const balanceResults = await publicClient.multicall({ contracts: balanceCalls }); + const balanceResults = await browserClient.multicall({ contracts: balanceCalls }); // Filter to only storylines with non-zero balance const held = storylines @@ -62,7 +61,7 @@ export function ReaderPortfolio() { const balance = balanceResult.result as bigint; try { const [price, priceChangeResult, tvlResult] = await Promise.all([ - publicClient.readContract({ + browserClient.readContract({ address: MCV2_BOND, abi: mcv2BondAbi, functionName: "priceForNextMint", diff --git a/src/components/WriterTradingStats.tsx b/src/components/WriterTradingStats.tsx index c4f3e0df..cfa09b62 100644 --- a/src/components/WriterTradingStats.tsx +++ b/src/components/WriterTradingStats.tsx @@ -2,9 +2,8 @@ import { useQuery } from "@tanstack/react-query"; import { formatUnits, type Address } from "viem"; -import { browserClient as publicClient } from "../../lib/rpc"; -import { mcv2BondAbi, getTokenTVL } from "../../lib/price"; import { browserClient } from "../../lib/rpc"; +import { mcv2BondAbi, getTokenTVL } from "../../lib/price"; import { MCV2_BOND, RESERVE_LABEL } from "../../lib/contracts/constants"; import { formatPrice } from "../../lib/format"; import type { Storyline } from "../../lib/supabase"; @@ -21,7 +20,7 @@ export function WriterTradingStats({ storyline }: WriterTradingStatsProps) { queryKey: ["writer-stats", tokenAddress], queryFn: async () => { const [priceRaw, tvlData] = await Promise.all([ - publicClient.readContract({ + browserClient.readContract({ address: MCV2_BOND, abi: mcv2BondAbi, functionName: "priceForNextMint",