From 91317e5012320b6b142fb32f31e42a779eaab5d2 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 21 Mar 2026 07:14:11 +0000 Subject: [PATCH 1/2] [#399] Add batch transport, memory cache, and expanded RPC endpoints - Enable batch: true on both browserClient and publicClient transports - Reduce timeouts from 5-10s to 2s for faster endpoint rotation - Expand CORS and server endpoint lists from 5/8 to 12 each - Add lib/cache.ts with singleton MemoryCache (60s TTL + in-flight dedup) - Wrap getTokenPrice, get24hPriceChange, getTokenTVL, getBatchTokenData with priceCache to eliminate duplicate concurrent RPC calls Fixes #399 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/cache.ts | 58 ++++++++++ lib/price.ts | 302 ++++++++++++++++++++++++++++----------------------- lib/rpc.ts | 29 ++++- 3 files changed, 245 insertions(+), 144 deletions(-) create mode 100644 lib/cache.ts diff --git a/lib/cache.ts b/lib/cache.ts new file mode 100644 index 00000000..d6b70952 --- /dev/null +++ b/lib/cache.ts @@ -0,0 +1,58 @@ +/** + * Singleton in-memory cache with TTL and in-flight request deduplication. + * Ported from mintpad's singleton-memory-cache pattern. + */ + +interface CacheEntry { + value: unknown; + expires: number; +} + +class MemoryCache { + private cache = new Map(); + private inFlight = new Map>(); + + async get(key: string, fetcher: () => Promise, ttlSeconds = 60): Promise { + const normalizedKey = key.toLowerCase(); + + // Return cached if fresh + const cached = this.cache.get(normalizedKey); + if (cached && Date.now() < cached.expires) { + return cached.value as T; + } + + // Dedup: if same key is already being fetched, await that promise + const pending = this.inFlight.get(normalizedKey); + if (pending) return pending as Promise; + + // Fetch, cache, and clean up + const promise = fetcher() + .then((value) => { + this.cache.set(normalizedKey, { + value, + expires: Date.now() + ttlSeconds * 1000, + }); + this.inFlight.delete(normalizedKey); + return value; + }) + .catch((err) => { + this.inFlight.delete(normalizedKey); + throw err; + }); + + this.inFlight.set(normalizedKey, promise); + return promise; + } + + delete(key: string): void { + const normalizedKey = key.toLowerCase(); + this.cache.delete(normalizedKey); + this.inFlight.delete(normalizedKey); + } + + clear(): void { + this.cache.clear(); + } +} + +export const priceCache = new MemoryCache(); diff --git a/lib/price.ts b/lib/price.ts index 3f644a86..aa6fd484 100644 --- a/lib/price.ts +++ b/lib/price.ts @@ -5,6 +5,7 @@ import { priceForNextMintFunction, tokenBondFunction, } from "./contracts/abi"; +import { priceCache } from "./cache"; /** * Minimal ABIs for price display. @@ -146,30 +147,36 @@ export async function getTokenPrice( client?: typeof publicClient, ): Promise { const rpc = client ?? publicClient; - try { - const [priceRaw, totalSupplyRaw] = await Promise.all([ - rpc.readContract({ - address: MCV2_BOND, - abi: mcv2BondAbi, - functionName: "priceForNextMint", - args: [tokenAddress], - }), - rpc.readContract({ - address: tokenAddress, - abi: erc20Abi, - functionName: "totalSupply", - }), - ]); + return priceCache.get( + `price:${tokenAddress.toLowerCase()}`, + async () => { + try { + const [priceRaw, totalSupplyRaw] = await Promise.all([ + rpc.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "priceForNextMint", + args: [tokenAddress], + }), + rpc.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: "totalSupply", + }), + ]); - return { - pricePerToken: formatUnits(priceRaw, 18), - priceRaw: BigInt(priceRaw), - totalSupply: formatUnits(totalSupplyRaw, 18), - totalSupplyRaw, - }; - } catch { - return null; - } + return { + pricePerToken: formatUnits(priceRaw, 18), + priceRaw: BigInt(priceRaw), + totalSupply: formatUnits(totalSupplyRaw, 18), + totalSupplyRaw, + }; + } catch { + return null; + } + }, + 60, + ); } /** ~24 hours of blocks on Base at 2s block time */ @@ -186,40 +193,46 @@ export async function get24hPriceChange( client?: typeof publicClient, ): Promise<{ changePercent: number; currentPrice: bigint; previousPrice: bigint } | null> { const rpc = client ?? publicClient; - try { - const currentBlock = await rpc.getBlockNumber(); - const pastBlock = currentBlock - BLOCKS_PER_24H; + return priceCache.get( + `24h:${tokenAddress.toLowerCase()}`, + async () => { + try { + const currentBlock = await rpc.getBlockNumber(); + const pastBlock = currentBlock - BLOCKS_PER_24H; - const [currentPrice, previousPrice] = await Promise.all([ - rpc.readContract({ - address: MCV2_BOND, - abi: mcv2BondAbi, - functionName: "priceForNextMint", - args: [tokenAddress], - }), - rpc.readContract({ - address: MCV2_BOND, - abi: mcv2BondAbi, - functionName: "priceForNextMint", - args: [tokenAddress], - blockNumber: pastBlock, - }), - ]); + const [currentPrice, previousPrice] = await Promise.all([ + rpc.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "priceForNextMint", + args: [tokenAddress], + }), + rpc.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "priceForNextMint", + args: [tokenAddress], + blockNumber: pastBlock, + }), + ]); - const current = BigInt(currentPrice); - const previous = BigInt(previousPrice); + const current = BigInt(currentPrice); + const previous = BigInt(previousPrice); - if (previous === BigInt(0)) { - return { changePercent: 0, currentPrice: current, previousPrice: previous }; - } + if (previous === BigInt(0)) { + return { changePercent: 0, currentPrice: current, previousPrice: previous }; + } - const changePercent = - Number(((current - previous) * BigInt(10000)) / previous) / 100; + const changePercent = + Number(((current - previous) * BigInt(10000)) / previous) / 100; - return { changePercent, currentPrice: current, previousPrice: previous }; - } catch { - return null; - } + return { changePercent, currentPrice: current, previousPrice: previous }; + } catch { + return null; + } + }, + 60, + ); } const erc20DecimalsAbi = [ @@ -243,32 +256,38 @@ export async function getTokenTVL( client?: typeof publicClient, ): Promise<{ tvl: string; tvlRaw: bigint; reserveToken: Address; decimals: number } | null> { const rpc = client ?? publicClient; - try { - const result = await rpc.readContract({ - address: MCV2_BOND, - abi: mcv2BondAbi, - functionName: "tokenBond", - args: [tokenAddress], - }); + return priceCache.get( + `tvl:${tokenAddress.toLowerCase()}`, + async () => { + try { + const result = await rpc.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "tokenBond", + args: [tokenAddress], + }); - const [, , , , reserveToken, reserveBalance] = result; - const reserveAddr = reserveToken as Address; + const [, , , , reserveToken, reserveBalance] = result; + const reserveAddr = reserveToken as Address; - const decimals = await rpc.readContract({ - address: reserveAddr, - abi: erc20DecimalsAbi, - functionName: "decimals", - }); + const decimals = await rpc.readContract({ + address: reserveAddr, + abi: erc20DecimalsAbi, + functionName: "decimals", + }); - return { - tvl: formatUnits(reserveBalance, decimals), - tvlRaw: reserveBalance, - reserveToken: reserveAddr, - decimals, - }; - } catch { - return null; - } + return { + tvl: formatUnits(reserveBalance, decimals), + tvlRaw: reserveBalance, + reserveToken: reserveAddr, + decimals, + }; + } catch { + return null; + } + }, + 60, + ); } // --------------------------------------------------------------------------- @@ -290,75 +309,82 @@ export async function getBatchTokenData( tokenAddresses: Address[], client?: typeof publicClient, ): Promise> { + if (tokenAddresses.length === 0) return new Map(); + + const cacheKey = `batch:${tokenAddresses.map((a) => a.toLowerCase()).sort().join(",")}`; 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, - }, - ]); + return priceCache.get( + cacheKey, + async () => { + const result = new Map(); - try { - const multicallResults = await rpcClient.multicall({ - contracts: calls, - allowFailure: true, - }); + 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, + }, + ]); - // 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]; + try { + const multicallResults = await rpcClient.multicall({ + contracts: calls, + allowFailure: true, + }); - 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, - }; - } + 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 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, - }; - } + 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, + }; + } - result.set(addr, { price, tvl }); - } - } catch { - // If multicall fails entirely, return empty map (callers fall back) - } + 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; + tvl = { + tvl: formatUnits(reserveBalance, 18), + tvlRaw: reserveBalance, + reserveToken, + decimals: 18, + }; + } - return result; + result.set(addr, { price, tvl }); + } + } catch { + // If multicall fails entirely, return empty map (callers fall back) + } + + return result; + }, + 60, + ); } diff --git a/lib/rpc.ts b/lib/rpc.ts index 68ff7794..35e01b49 100644 --- a/lib/rpc.ts +++ b/lib/rpc.ts @@ -18,9 +18,13 @@ const PUBLIC_RPC_ENDPOINTS = [ "https://base.drpc.org", "https://base.llamarpc.com", "https://base.meowrpc.com", - "https://developer-access-mainnet.base.org", "https://base-mainnet.public.blastapi.io", "https://1rpc.io/base", + "https://base.gateway.tenderly.co", + "https://rpc.notadegen.com/base", + "https://base.blockpi.network/v1/rpc/public", + "https://developer-access-mainnet.base.org", + "https://base.api.onfinality.io/public", ]; export const RPC_ENDPOINTS = CUSTOM_RPC_URL @@ -29,11 +33,18 @@ export const RPC_ENDPOINTS = CUSTOM_RPC_URL /** Client-side CORS-enabled RPC endpoints for wagmi/browser. */ const PUBLIC_CORS_ENDPOINTS = [ - "https://mainnet.base.org", "https://base-rpc.publicnode.com", + "https://mainnet.base.org", "https://base.drpc.org", "https://base.llamarpc.com", "https://base.meowrpc.com", + "https://base-mainnet.public.blastapi.io", + "https://1rpc.io/base", + "https://base.gateway.tenderly.co", + "https://rpc.notadegen.com/base", + "https://base.blockpi.network/v1/rpc/public", + "https://developer-access-mainnet.base.org", + "https://base.api.onfinality.io/public", ]; export const CORS_RPC_ENDPOINTS = CUSTOM_RPC_URL @@ -49,7 +60,7 @@ function buildServerTransport() { return CUSTOM_RPC_URL ? fallback([http(CUSTOM_RPC_URL), http()]) : http(); } return fallback( - RPC_ENDPOINTS.map((url) => http(url, { timeout: 10_000, retryCount: 1 })), + RPC_ENDPOINTS.map((url) => http(url, { timeout: 2_000, retryCount: 0, batch: true })), { rank: false }, ); } @@ -77,8 +88,9 @@ export const browserClient = createPublicClient({ ? fallback( CORS_RPC_ENDPOINTS.map((url) => http(url, { - timeout: 5_000, + timeout: 2_000, retryCount: 0, + batch: true, fetchOptions: { mode: "cors", credentials: "omit" }, }), ), @@ -104,8 +116,9 @@ export function createFallbackTransport() { return fallback( CORS_RPC_ENDPOINTS.map((url) => http(url, { - timeout: 5_000, + timeout: 2_000, retryCount: 0, + batch: true, fetchOptions: { mode: "cors", credentials: "omit" }, }), ), @@ -126,7 +139,11 @@ function getRpcDisplayName(url: string): string { if (url.includes("meowrpc.com")) return "MeowRPC"; if (url.includes("1rpc.io")) return "1RPC"; if (url.includes("blastapi.io")) return "BlastAPI"; + if (url.includes("tenderly.co")) return "Tenderly"; + if (url.includes("notadegen.com")) return "NotADegen"; + if (url.includes("blockpi.network")) return "BlockPI"; if (url.includes("developer-access")) return "Base Dev"; + if (url.includes("onfinality.io")) return "OnFinality"; return "RPC"; } @@ -149,7 +166,7 @@ export async function withServerRpcFallback( try { const client = createPublicClient({ chain, - transport: http(url, { timeout: 10_000, retryCount: 0 }), + transport: http(url, { timeout: 2_000, retryCount: 0 }), }) as PublicClient; const result = await operation(client); if (i > 0) console.log(`${prefix} Success with ${name} (attempt ${i + 1})`); From dbda39feeda0eade545166b4187b850f0bb84e47 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 21 Mar 2026 07:16:40 +0000 Subject: [PATCH 2/2] [#399] Fix cached null on RPC errors, bypass cache for custom clients - Remove try/catch from inside cache fetchers so transient RPC failures propagate as errors (cache.get's .catch path correctly skips caching) - Outer try/catch at function level still returns null to callers - When a custom client is passed, bypass the cache entirely to avoid returning stale results from a different RPC client Addresses T2b review feedback on PR #400. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/price.ts | 340 +++++++++++++++++++++++++-------------------------- 1 file changed, 166 insertions(+), 174 deletions(-) diff --git a/lib/price.ts b/lib/price.ts index aa6fd484..bc40f649 100644 --- a/lib/price.ts +++ b/lib/price.ts @@ -147,36 +147,35 @@ export async function getTokenPrice( client?: typeof publicClient, ): Promise { const rpc = client ?? publicClient; - return priceCache.get( - `price:${tokenAddress.toLowerCase()}`, - async () => { - try { - const [priceRaw, totalSupplyRaw] = await Promise.all([ - rpc.readContract({ - address: MCV2_BOND, - abi: mcv2BondAbi, - functionName: "priceForNextMint", - args: [tokenAddress], - }), - rpc.readContract({ - address: tokenAddress, - abi: erc20Abi, - functionName: "totalSupply", - }), - ]); - - return { - pricePerToken: formatUnits(priceRaw, 18), - priceRaw: BigInt(priceRaw), - totalSupply: formatUnits(totalSupplyRaw, 18), - totalSupplyRaw, - }; - } catch { - return null; - } - }, - 60, - ); + const fetcher = async () => { + const [priceRaw, totalSupplyRaw] = await Promise.all([ + rpc.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "priceForNextMint", + args: [tokenAddress], + }), + rpc.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: "totalSupply", + }), + ]); + + return { + pricePerToken: formatUnits(priceRaw, 18), + priceRaw: BigInt(priceRaw), + totalSupply: formatUnits(totalSupplyRaw, 18), + totalSupplyRaw, + } satisfies TokenPriceInfo; + }; + + try { + if (client) return await fetcher(); + return await priceCache.get(`price:${tokenAddress.toLowerCase()}`, fetcher, 60); + } catch { + return null; + } } /** ~24 hours of blocks on Base at 2s block time */ @@ -193,46 +192,45 @@ export async function get24hPriceChange( client?: typeof publicClient, ): Promise<{ changePercent: number; currentPrice: bigint; previousPrice: bigint } | null> { const rpc = client ?? publicClient; - return priceCache.get( - `24h:${tokenAddress.toLowerCase()}`, - async () => { - try { - const currentBlock = await rpc.getBlockNumber(); - const pastBlock = currentBlock - BLOCKS_PER_24H; - - const [currentPrice, previousPrice] = await Promise.all([ - rpc.readContract({ - address: MCV2_BOND, - abi: mcv2BondAbi, - functionName: "priceForNextMint", - args: [tokenAddress], - }), - rpc.readContract({ - address: MCV2_BOND, - abi: mcv2BondAbi, - functionName: "priceForNextMint", - args: [tokenAddress], - blockNumber: pastBlock, - }), - ]); - - const current = BigInt(currentPrice); - const previous = BigInt(previousPrice); - - if (previous === BigInt(0)) { - return { changePercent: 0, currentPrice: current, previousPrice: previous }; - } - - const changePercent = - Number(((current - previous) * BigInt(10000)) / previous) / 100; - - return { changePercent, currentPrice: current, previousPrice: previous }; - } catch { - return null; - } - }, - 60, - ); + const fetcher = async () => { + const currentBlock = await rpc.getBlockNumber(); + const pastBlock = currentBlock - BLOCKS_PER_24H; + + const [currentPrice, previousPrice] = await Promise.all([ + rpc.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "priceForNextMint", + args: [tokenAddress], + }), + rpc.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "priceForNextMint", + args: [tokenAddress], + blockNumber: pastBlock, + }), + ]); + + const current = BigInt(currentPrice); + const previous = BigInt(previousPrice); + + if (previous === BigInt(0)) { + return { changePercent: 0, currentPrice: current, previousPrice: previous }; + } + + const changePercent = + Number(((current - previous) * BigInt(10000)) / previous) / 100; + + return { changePercent, currentPrice: current, previousPrice: previous }; + }; + + try { + if (client) return await fetcher(); + return await priceCache.get(`24h:${tokenAddress.toLowerCase()}`, fetcher, 60); + } catch { + return null; + } } const erc20DecimalsAbi = [ @@ -256,38 +254,37 @@ export async function getTokenTVL( client?: typeof publicClient, ): Promise<{ tvl: string; tvlRaw: bigint; reserveToken: Address; decimals: number } | null> { const rpc = client ?? publicClient; - return priceCache.get( - `tvl:${tokenAddress.toLowerCase()}`, - async () => { - try { - const result = await rpc.readContract({ - address: MCV2_BOND, - abi: mcv2BondAbi, - functionName: "tokenBond", - args: [tokenAddress], - }); - - const [, , , , reserveToken, reserveBalance] = result; - const reserveAddr = reserveToken as Address; - - const decimals = await rpc.readContract({ - address: reserveAddr, - abi: erc20DecimalsAbi, - functionName: "decimals", - }); - - return { - tvl: formatUnits(reserveBalance, decimals), - tvlRaw: reserveBalance, - reserveToken: reserveAddr, - decimals, - }; - } catch { - return null; - } - }, - 60, - ); + const fetcher = async () => { + const result = await rpc.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "tokenBond", + args: [tokenAddress], + }); + + const [, , , , reserveToken, reserveBalance] = result; + const reserveAddr = reserveToken as Address; + + const decimals = await rpc.readContract({ + address: reserveAddr, + abi: erc20DecimalsAbi, + functionName: "decimals", + }); + + return { + tvl: formatUnits(reserveBalance, decimals), + tvlRaw: reserveBalance, + reserveToken: reserveAddr, + decimals, + }; + }; + + try { + if (client) return await fetcher(); + return await priceCache.get(`tvl:${tokenAddress.toLowerCase()}`, fetcher, 60); + } catch { + return null; + } } // --------------------------------------------------------------------------- @@ -311,80 +308,75 @@ export async function getBatchTokenData( ): Promise> { if (tokenAddresses.length === 0) return new Map(); - const cacheKey = `batch:${tokenAddresses.map((a) => a.toLowerCase()).sort().join(",")}`; const rpcClient = client ?? publicClient; + const fetcher = async () => { + const result = new Map(); + + 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, + }, + ]); + + const multicallResults = await rpcClient.multicall({ + contracts: calls, + allowFailure: true, + }); + + 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, + }; + } - return priceCache.get( - cacheKey, - async () => { - const result = new Map(); - - 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, - }); - - 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; - 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) + 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; + tvl = { + tvl: formatUnits(reserveBalance, 18), + tvlRaw: reserveBalance, + reserveToken, + decimals: 18, + }; } - return result; - }, - 60, - ); + result.set(addr, { price, tvl }); + } + + return result; + }; + + if (client) return fetcher().catch(() => new Map()); + + const cacheKey = `batch:${tokenAddresses.map((a) => a.toLowerCase()).sort().join(",")}`; + return priceCache.get(cacheKey, fetcher, 60).catch(() => new Map()); }