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..bc40f649 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,7 +147,7 @@ export async function getTokenPrice( client?: typeof publicClient, ): Promise { const rpc = client ?? publicClient; - try { + const fetcher = async () => { const [priceRaw, totalSupplyRaw] = await Promise.all([ rpc.readContract({ address: MCV2_BOND, @@ -166,7 +167,12 @@ export async function getTokenPrice( 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; } @@ -186,7 +192,7 @@ export async function get24hPriceChange( client?: typeof publicClient, ): Promise<{ changePercent: number; currentPrice: bigint; previousPrice: bigint } | null> { const rpc = client ?? publicClient; - try { + const fetcher = async () => { const currentBlock = await rpc.getBlockNumber(); const pastBlock = currentBlock - BLOCKS_PER_24H; @@ -217,6 +223,11 @@ export async function get24hPriceChange( 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; } @@ -243,7 +254,7 @@ export async function getTokenTVL( client?: typeof publicClient, ): Promise<{ tvl: string; tvlRaw: bigint; reserveToken: Address; decimals: number } | null> { const rpc = client ?? publicClient; - try { + const fetcher = async () => { const result = await rpc.readContract({ address: MCV2_BOND, abi: mcv2BondAbi, @@ -266,6 +277,11 @@ export async function getTokenTVL( reserveToken: reserveAddr, decimals, }; + }; + + try { + if (client) return await fetcher(); + return await priceCache.get(`tvl:${tokenAddress.toLowerCase()}`, fetcher, 60); } catch { return null; } @@ -290,37 +306,37 @@ export async function getBatchTokenData( tokenAddresses: Address[], client?: typeof publicClient, ): Promise> { + if (tokenAddresses.length === 0) return new Map(); + 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, - }, - ]); + 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, + }, + ]); - 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; @@ -345,7 +361,6 @@ export async function getBatchTokenData( 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, @@ -356,9 +371,12 @@ export async function getBatchTokenData( result.set(addr, { price, tvl }); } - } catch { - // If multicall fails entirely, return empty map (callers fall back) - } - return result; + 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()); } 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})`);