From 2ca044ba00a6a67142e2a720bee1d37139137abb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:52:15 +0000 Subject: [PATCH 1/4] fix(prices): batch and cache Alchemy price lookups to stay under the quota Production has been getting 429s with "Your payg app has exceeded its limit of 10000 token_price requests per 1 hours" (Sentry SOLID-PJ, SOLID-DV). That quota counts requests, and one request can carry 25 tokens, but every price was its own request and nothing was reused: - useBalances refreshed every 5s, and each refresh sent five by-symbol GETs (ETH once per chain, FUSE, BNB) plus one per token still unpriced. That is about 3,600 requests an hour for a single open app, so two or three users used up the whole app's quota. - fusePriceUsd / ethPriceUsd polled every 5s on top of that. Changes: - lib/batchedLoader merges lookups started in the same tick into requests of up to 25. It caches prices for a minute (misses for five), keeps serving the last price while a refresh fails, and pauses both Prices endpoints after a 429. - fetchTokenPriceUsd and the new fetchTokenPricesBySymbol go through it. By-address requests stay within the documented limits of 25 addresses and 3 networks. - Balance fallback poll goes from 5s to 30s (SSE already invalidates on every balance event), native price refresh from 5s to 60s, and vault balance poll from 3s to 30s. - publicClient keeps one client per chain with Multicall3 batching, so reads made together share one eth_call. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UMuZxhszcH7Kc5s7dc2NNq --- constants/alchemy.ts | 13 +- hooks/useBalances.ts | 74 ++++---- hooks/useEstimateGas.ts | 3 + hooks/useNativePriceUsd.ts | 25 ++- hooks/useToken.tsx | 5 +- hooks/useTotalSavingsUSD.ts | 30 ++- hooks/useVault.ts | 14 +- lib/__tests__/alchemyTokenPrices.test.ts | 162 +++++++++++++++- lib/__tests__/batchedLoader.test.ts | 187 +++++++++++++++++++ lib/api.ts | 226 +++++++++++++++++------ lib/batchedLoader.ts | 128 +++++++++++++ lib/wagmi.ts | 32 +++- 12 files changed, 758 insertions(+), 141 deletions(-) create mode 100644 lib/__tests__/batchedLoader.test.ts create mode 100644 lib/batchedLoader.ts diff --git a/constants/alchemy.ts b/constants/alchemy.ts index 9383b7edf..f37c1b368 100644 --- a/constants/alchemy.ts +++ b/constants/alchemy.ts @@ -41,7 +41,18 @@ export const ALCHEMY_NETWORKS: Record = { export const ALCHEMY_PRICES_URL = `https://api.g.alchemy.com/prices/v1/${EXPO_PUBLIC_ALCHEMY_API_KEY}/tokens`; -/** Addresses per `tokens/by-address` request — Alchemy rejects more than this. */ +/** + * Symbols per `tokens/by-symbol` request, and addresses per `tokens/by-address` + * request — Alchemy rejects more than this. A request costs the same 40 CU and + * one unit of the hourly token_price quota however many it carries. + */ export const ALCHEMY_PRICE_BATCH_SIZE = 25; +/** + * Distinct networks per `tokens/by-address` request, per Alchemy's docs. The + * endpoint accepts more today, but a request past a documented limit is one + * enforcement change away from failing whole. + */ +export const ALCHEMY_PRICE_MAX_NETWORKS = 3; + export const ALCHEMY_REQUEST_TIMEOUT_MS = 10_000; diff --git a/hooks/useBalances.ts b/hooks/useBalances.ts index bc3007262..c060822f9 100644 --- a/hooks/useBalances.ts +++ b/hooks/useBalances.ts @@ -8,7 +8,7 @@ import { fetchCoinSimplePrice, fetchTokenList, fetchTokenPricesByAddress, - fetchTokenPriceUsd, + fetchTokenPricesBySymbol, } from '@/lib/api'; import { ADDRESSES } from '@/lib/config'; import { fetchTokenBalancesWithFallback } from '@/lib/data-source'; @@ -108,9 +108,10 @@ const symbols = { /** * Native-token USD price per chain. Uses the shared fetcher (Alchemy by symbol, - * CoinGecko on failure) rather than a bare Alchemy call: FUSE's "symbol" here is - * a CoinGecko coin id that Alchemy's by-symbol endpoint never resolves, so on - * its own it yields no price at all. + * CoinGecko on failure) rather than a bare Alchemy call, so a chain still gets + * a price while Alchemy has none or is rate-limiting us. The five lookups start + * in the same tick, so they ride one Prices API request — ETH is asked for once, + * not once per chain — and are served from cache for the next minute. */ const NATIVE_PRICE_FETCHERS: Record Promise> = { [mainnet.id]: makeNativePriceFetcher(mainnet.id), @@ -571,7 +572,8 @@ const fetchTokenBalances = async (safeAddress: string) => { } } - // Fallback 3: Alchemy by symbol for tokens still at 0 (no tokenId) + // Fallback 3: Alchemy by symbol for tokens still at 0 (no tokenId). One + // request carries up to 25 symbols; this used to be a request per symbol. const stillZero = allTokens.filter( t => isZeroRate(t.quoteRate) && @@ -580,25 +582,13 @@ const fetchTokenBalances = async (safeAddress: string) => { ); const symbolsToFetch = [...new Set(stillZero.map(t => t.contractTickerSymbol))]; if (symbolsToFetch.length > 0) { - try { - const results = await Promise.allSettled(symbolsToFetch.map(s => fetchTokenPriceUsd(s))); - const symbolToPrice: Record = {}; - symbolsToFetch.forEach((sym, i) => { - const r = results[i]; - if (r.status === 'fulfilled') { - const p = parsePrice(r.value); - if (p != null && p > 0) symbolToPrice[sym] = p; - } - }); - allTokens = allTokens.map(t => { - if (!isZeroRate(t.quoteRate) || isUnderlyingPricedShare(t.contractAddress)) return t; - const p = t.contractTickerSymbol && symbolToPrice[t.contractTickerSymbol]; - if (typeof p === 'number') return { ...t, quoteRate: p }; - return t; - }); - } catch (e) { - console.warn('Alchemy fallback price failed:', e); - } + const symbolToPrice = await fetchTokenPricesBySymbol(symbolsToFetch); + allTokens = allTokens.map(t => { + if (!isZeroRate(t.quoteRate) || isUnderlyingPricedShare(t.contractAddress)) return t; + const p = t.contractTickerSymbol && symbolToPrice[t.contractTickerSymbol]; + if (typeof p === 'number') return { ...t, quoteRate: p }; + return t; + }); } // Helper function to calculate token value @@ -732,22 +722,34 @@ const EMPTY_BALANCE_DATA = { unifiedTokens: [] as UnifiedTokenBalance[], }; +/** Shared by useBalances and the protected layout's prefetch. */ +export const tokenBalancesQueryOptions = (safeAddress: string | undefined) => ({ + queryKey: ['tokenBalances', safeAddress], + queryFn: () => fetchTokenBalances(safeAddress!), + enabled: !!safeAddress, + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, +}); + +/** + * SSE handles real-time updates (useActivitySSE invalidates ['tokenBalances'] + * on every balance event); polling is only the fallback for missed events or + * SSE failure. Each refresh is ~11 Alchemy RPC calls across five chains plus + * the price lookups, and at the old 5s one open app alone spent over a third + * of the app-wide 10,000/hour Prices API quota. + */ +const BALANCES_POLL_INTERVAL_MS = 30_000; + export const useBalances = (): BalanceData => { const { user } = useUser(); const { data, isLoading, isRefetching, error, refetch } = useQuery({ - queryKey: ['tokenBalances', user?.safeAddress], - queryFn: () => fetchTokenBalances(user?.safeAddress!), - enabled: !!user?.safeAddress, - // TanStack Query handles all the manual logic: - staleTime: 5_000, - gcTime: 5 * 60 * 1000, // 5 minutes - data stays in cache for 5 minutes when unused + ...tokenBalancesQueryOptions(user?.safeAddress), retry: 3, // retry up to 3 times on failure retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff refetchOnWindowFocus: true, // refetch when user returns to tab refetchOnReconnect: true, // refetch when network reconnects - // SSE handles real-time updates; polling is fallback for missed events or SSE failure - refetchInterval: 5_000, + refetchInterval: BALANCES_POLL_INTERVAL_MS, refetchIntervalInBackground: false, // Don't refetch when app is backgrounded (saves battery) }); @@ -761,11 +763,3 @@ export const useBalances = (): BalanceData => { retry: refetch, }; }; - -export const tokenBalancesQueryOptions = (safeAddress: string | undefined) => ({ - queryKey: ['tokenBalances', safeAddress], - queryFn: () => fetchTokenBalances(safeAddress!), - enabled: !!safeAddress, - staleTime: 30 * 1000, - gcTime: 5 * 60 * 1000, -}); diff --git a/hooks/useEstimateGas.ts b/hooks/useEstimateGas.ts index f5adacea8..62734f000 100644 --- a/hooks/useEstimateGas.ts +++ b/hooks/useEstimateGas.ts @@ -20,6 +20,9 @@ async function estimateGasCost( const fastGasPrice = (baseGasPrice * 195n) / 100n; // 85% above base for fast transactions const tokenPriceUsd = await fetchTokenPriceUsd(token); + // The price lookup reports "no price" instead of throwing; fail the query as + // before, so the cost shows as 0 rather than NaN. + if (tokenPriceUsd === undefined) throw new Error(`No USD price for ${token}`); const gasCostInWei = gasEstimate * fastGasPrice; diff --git a/hooks/useNativePriceUsd.ts b/hooks/useNativePriceUsd.ts index d77dcf65d..6e4ce39bd 100644 --- a/hooks/useNativePriceUsd.ts +++ b/hooks/useNativePriceUsd.ts @@ -3,6 +3,13 @@ import { useQuery } from '@tanstack/react-query'; import { NATIVE_COINGECKO_TOKENS, NATIVE_TOKENS } from '@/constants/tokens'; import { fetchCoinSimplePrice, fetchTokenPriceUsd } from '@/lib/api'; +/** + * How often native-token prices refresh. Alchemy updates its prices about once + * a minute, so polling faster (this was 5s) only spent Prices API quota on the + * same number. + */ +export const NATIVE_PRICE_REFRESH_MS = 60_000; + /** * Native-token USD price fetcher for a chain (Alchemy first, CoinGecko fallback). * Shared by useTotalSavingsUSD (FUSE + ETH terms) and the per-vault savings card. @@ -24,17 +31,21 @@ export const makeNativePriceFetcher = }; /** - * USD price of a chain's native token as a number (0 when disabled/unavailable). - * `queryKey` is shared with useTotalSavingsUSD ('fusePriceUsd' / 'ethPriceUsd') - * so the two hooks reuse one cached request. + * Query for a chain's native-token USD price. `queryKey` is shared by every + * caller for the same chain ('fusePriceUsd' / 'ethPriceUsd'), so they all read + * one cached request. */ -export const useNativePriceUsd = (chainId: number, queryKey: string, enabled: boolean): number => { - const { data } = useQuery({ +export const useNativePriceQuery = (chainId: number, queryKey: string, enabled: boolean) => + useQuery({ queryKey: [queryKey], queryFn: makeNativePriceFetcher(chainId), enabled, - staleTime: 5_000, - refetchInterval: 5_000, + staleTime: NATIVE_PRICE_REFRESH_MS, + refetchInterval: NATIVE_PRICE_REFRESH_MS, }); + +/** USD price of a chain's native token as a number (0 when disabled/unavailable). */ +export const useNativePriceUsd = (chainId: number, queryKey: string, enabled: boolean): number => { + const { data } = useNativePriceQuery(chainId, queryKey, enabled); return enabled ? Number(data) || 0 : 0; }; diff --git a/hooks/useToken.tsx b/hooks/useToken.tsx index 6921da4a1..213735186 100644 --- a/hooks/useToken.tsx +++ b/hooks/useToken.tsx @@ -1,5 +1,5 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Address, erc20Abi, formatUnits } from 'viem'; import { mainnet } from 'viem/chains'; import { readContractQueryOptions } from 'wagmi/query'; @@ -21,7 +21,8 @@ type Balance = { export const tokenPriceUsdQueryOptions = (tokenId: string) => { return { queryKey: ['tokenPriceUsd', tokenId], - queryFn: () => fetchTokenPriceUsd(tokenId), + // null, not undefined: TanStack Query rejects undefined as query data. + queryFn: async () => (await fetchTokenPriceUsd(tokenId)) ?? null, enabled: !!tokenId, }; }; diff --git a/hooks/useTotalSavingsUSD.ts b/hooks/useTotalSavingsUSD.ts index 80fe8d515..37692c056 100644 --- a/hooks/useTotalSavingsUSD.ts +++ b/hooks/useTotalSavingsUSD.ts @@ -1,10 +1,9 @@ import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; import { Address } from 'viem'; import { fuse, mainnet } from 'viem/chains'; import { VAULTS } from '@/constants/vaults'; -import { makeNativePriceFetcher } from '@/hooks/useNativePriceUsd'; +import { useNativePriceQuery } from '@/hooks/useNativePriceUsd'; import useUser from '@/hooks/useUser'; import { useVaultBalance } from '@/hooks/useVault'; import { useVaultExchangeRate } from '@/hooks/useVaultExchangeRate'; @@ -15,9 +14,6 @@ const usdcVault = ACTIVE_VAULTS[0]; const fuseVault = ACTIVE_VAULTS[1]; const ethVault = ACTIVE_VAULTS[2]; -const fetchFusePrice = makeNativePriceFetcher(fuse.id); -const fetchEthPrice = makeNativePriceFetcher(mainnet.id); - /** * Total redeemable savings in USD across ALL vaults: soUSD + soFUSE + soETH. * USDC uses its soUSD→USD rate directly; FUSE and ETH multiply their @@ -59,20 +55,16 @@ export const useTotalSavingsUSD = (): { const hasFuseBalance = !!fuseVault && (balanceFuse ?? 0) > 0; const hasEthBalance = !!ethVault && (balanceEth ?? 0) > 0; - const { data: fusePriceUsd, isLoading: isLoadingFusePrice } = useQuery({ - queryKey: ['fusePriceUsd'], - queryFn: fetchFusePrice, - enabled: hasFuseBalance, - staleTime: 5_000, - refetchInterval: 5_000, - }); - const { data: ethPriceUsd, isLoading: isLoadingEthPrice } = useQuery({ - queryKey: ['ethPriceUsd'], - queryFn: fetchEthPrice, - enabled: hasEthBalance, - staleTime: 5_000, - refetchInterval: 5_000, - }); + const { data: fusePriceUsd, isLoading: isLoadingFusePrice } = useNativePriceQuery( + fuse.id, + 'fusePriceUsd', + hasFuseBalance, + ); + const { data: ethPriceUsd, isLoading: isLoadingEthPrice } = useNativePriceQuery( + mainnet.id, + 'ethPriceUsd', + hasEthBalance, + ); const isLoading = isLoadingBalanceUsdc || diff --git a/hooks/useVault.ts b/hooks/useVault.ts index c45623a47..880baa2c6 100644 --- a/hooks/useVault.ts +++ b/hooks/useVault.ts @@ -13,7 +13,13 @@ import { config } from '@/lib/wagmi'; // Cache configuration for vault queries const VAULT_STALE_TIME = secondsToMilliseconds(3); // Consider data fresh for 3 seconds const VAULT_GC_TIME = secondsToMilliseconds(300); // Keep in cache for 5 minutes -const VAULT_REFETCH_INTERVAL = secondsToMilliseconds(3); // Poll every 3 seconds for near-realtime updates +/** + * Fallback poll. Real-time updates come from SSE, which invalidates ['vault'] on + * every balance event (useActivitySSE), and withdrawals invalidate it too. The + * old 3s poll read the two Ethereum vaults over Alchemy ~40 times a minute per + * open app, for a balance that only moves on a transaction. + */ +const VAULT_REFETCH_INTERVAL = secondsToMilliseconds(30); export const VAULT = 'vault'; @@ -32,7 +38,11 @@ export const fetchVaultBalance = async ( args: [safeAddress], chainId: chainId, }), - staleTime: VAULT_STALE_TIME, + // Always read the chain (concurrent reads of one vault still share a + // request): the vault queries are refetched when SSE reports a balance + // change, and a read cached from just before the transaction would answer + // that refetch with the old balance until the next poll. + staleTime: 0, }); return Number(formatUnits(balance, decimals)) || 0; diff --git a/lib/__tests__/alchemyTokenPrices.test.ts b/lib/__tests__/alchemyTokenPrices.test.ts index 6e7b2be7c..d06051af2 100644 --- a/lib/__tests__/alchemyTokenPrices.test.ts +++ b/lib/__tests__/alchemyTokenPrices.test.ts @@ -31,8 +31,14 @@ jest.mock('axios', () => { }); /* eslint-disable @typescript-eslint/no-require-imports */ -const post = (require('axios') as { default: { post: jest.Mock } }).default.post; -const { fetchTokenPricesByAddress } = require('@/lib/api') as typeof import('@/lib/api'); +const { post, get } = (require('axios') as { default: { post: jest.Mock; get: jest.Mock } }) + .default; +const { + clearAlchemyPriceCache, + fetchTokenPricesByAddress, + fetchTokenPricesBySymbol, + fetchTokenPriceUsd, +} = require('@/lib/api') as typeof import('@/lib/api'); /* eslint-enable @typescript-eslint/no-require-imports */ const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; @@ -45,7 +51,128 @@ const priced = (network: string, address: string, value: string) => ({ prices: [{ currency: 'usd', value, lastUpdatedAt: '2026-09-09T00:00:00Z' }], }); -beforeEach(() => post.mockReset()); +const bySymbol = (symbol: string, value?: string) => ({ + symbol, + prices: value ? [{ currency: 'usd', value, lastUpdatedAt: '2026-09-09T00:00:00Z' }] : [], + ...(value ? {} : { error: { message: `Price not found for symbol: ${symbol}` } }), +}); + +/** Symbols a by-symbol GET asked for, in order. */ +const requestedSymbols = (url: string) => + new URL(url).searchParams.getAll('symbols').map(s => decodeURIComponent(s)); + +const rateLimited = () => + Object.assign(new Error('Request failed with status code 429'), { + response: { status: 429, headers: {} }, + }); + +beforeEach(() => { + post.mockReset(); + get.mockReset(); + // Prices are shared module-wide for a minute; start every test cold. + clearAlchemyPriceCache(); +}); + +describe('fetchTokenPricesBySymbol', () => { + it('asks for every symbol looked up in the same tick in one request', async () => { + get.mockResolvedValue({ + data: { data: [bySymbol('ETH', '2658.09'), bySymbol('BNB', '765.87')] }, + }); + + // The native fetchers in useBalances ask for ETH once per chain. + const [ethereum, bsc, base] = await Promise.all([ + fetchTokenPriceUsd('ETH'), + fetchTokenPriceUsd('BNB'), + fetchTokenPriceUsd('ETH'), + ]); + + expect(get).toHaveBeenCalledTimes(1); + expect(get.mock.calls[0][0]).toContain('/tokens/by-symbol?'); + expect(requestedSymbols(get.mock.calls[0][0])).toEqual(['ETH', 'BNB']); + expect([ethereum, bsc, base]).toEqual(['2658.09', '765.87', '2658.09']); + }); + + it('matches the upper-cased symbols Alchemy echoes back', async () => { + get.mockResolvedValue({ + data: { data: [bySymbol('FUSE-NETWORK-TOKEN', '0.00805'), bySymbol('SOUSD', '1.066')] }, + }); + + await expect(fetchTokenPricesBySymbol(['fuse-network-token', 'soUSD'])).resolves.toEqual({ + 'fuse-network-token': 0.00805, + soUSD: 1.066, + }); + }); + + it('chunks past the 25-symbol cap', async () => { + const symbols = Array.from({ length: ALCHEMY_PRICE_BATCH_SIZE + 2 }, (_, i) => `TOKEN${i}`); + get.mockImplementation((url: string) => + Promise.resolve({ data: { data: requestedSymbols(url).map(s => bySymbol(s, '1')) } }), + ); + + const prices = await fetchTokenPricesBySymbol(symbols); + + expect(get).toHaveBeenCalledTimes(2); + expect(requestedSymbols(get.mock.calls[0][0])).toHaveLength(ALCHEMY_PRICE_BATCH_SIZE); + expect(requestedSymbols(get.mock.calls[1][0])).toHaveLength(2); + expect(Object.keys(prices)).toHaveLength(ALCHEMY_PRICE_BATCH_SIZE + 2); + }); + + it('encodes symbols that are not URL-safe', async () => { + get.mockResolvedValue({ data: { data: [] } }); + + await fetchTokenPricesBySymbol(['G$', 'USDC.E']); + + expect(get.mock.calls[0][0]).toContain('symbols=G%24&symbols=USDC.E'); + }); + + it('reuses a price for later callers instead of asking again', async () => { + get.mockResolvedValue({ data: { data: [bySymbol('ETH', '2658.09')] } }); + + await fetchTokenPriceUsd('ETH'); + await expect(fetchTokenPriceUsd('ETH')).resolves.toBe('2658.09'); + + expect(get).toHaveBeenCalledTimes(1); + }); + + it('remembers symbols Alchemy has no price for', async () => { + get.mockResolvedValue({ data: { data: [bySymbol('NOTAREALTOKEN')] } }); + + await expect(fetchTokenPriceUsd('NOTAREALTOKEN')).resolves.toBeUndefined(); + await expect(fetchTokenPriceUsd('NOTAREALTOKEN')).resolves.toBeUndefined(); + + expect(get).toHaveBeenCalledTimes(1); + }); + + it('stops calling either endpoint once the token_price quota is spent', async () => { + get.mockRejectedValueOnce(rateLimited()); + + await expect(fetchTokenPriceUsd('ETH')).resolves.toBeUndefined(); + await expect(fetchTokenPricesBySymbol(['BNB'])).resolves.toEqual({}); + await expect( + fetchTokenPricesByAddress([{ chainId: 8453, address: USDC_BASE }]), + ).resolves.toEqual({}); + + expect(get).toHaveBeenCalledTimes(1); + expect(post).not.toHaveBeenCalled(); + }); + + it('keeps showing the last price while rate-limited', async () => { + const now = jest.spyOn(Date, 'now'); + try { + now.mockReturnValue(1_000_000); + get.mockResolvedValueOnce({ data: { data: [bySymbol('ETH', '2658.09')] } }); + await fetchTokenPriceUsd('ETH'); + + now.mockReturnValue(1_000_000 + 61_000); // past the one-minute TTL + get.mockRejectedValueOnce(rateLimited()); + + await expect(fetchTokenPriceUsd('ETH')).resolves.toBe('2658.09'); + expect(get).toHaveBeenCalledTimes(2); + } finally { + now.mockRestore(); + } + }); +}); describe('fetchTokenPricesByAddress', () => { it('keys prices by chain id and lowercased address', async () => { @@ -177,4 +304,33 @@ describe('fetchTokenPricesByAddress', () => { await expect(fetchTokenPricesByAddress([])).resolves.toEqual({}); expect(post).not.toHaveBeenCalled(); }); + + it('keeps each request within the three-network cap', async () => { + post.mockResolvedValue({ data: { data: [] } }); + + // One token on each of the five Alchemy-served chains. + await fetchTokenPricesByAddress( + [1, 8453, 137, 42161, 56].map(chainId => ({ chainId, address: USDC_BASE })), + ); + + expect(post).toHaveBeenCalledTimes(2); + const networks = post.mock.calls.map( + ([, body]: [string, { addresses: { network: string }[] }]) => + new Set(body.addresses.map(a => a.network)).size, + ); + expect(networks).toEqual([3, 2]); + }); + + it('reuses prices for later callers instead of asking again', async () => { + post.mockResolvedValue({ + data: { data: [priced('base-mainnet', USDC_BASE.toLowerCase(), '1.0001')] }, + }); + + await fetchTokenPricesByAddress([{ chainId: 8453, address: USDC_BASE }]); + await expect( + fetchTokenPricesByAddress([{ chainId: 8453, address: USDC_BASE }]), + ).resolves.toEqual({ [`8453:${USDC_BASE.toLowerCase()}`]: 1.0001 }); + + expect(post).toHaveBeenCalledTimes(1); + }); }); diff --git a/lib/__tests__/batchedLoader.test.ts b/lib/__tests__/batchedLoader.test.ts new file mode 100644 index 000000000..b9f7b89d1 --- /dev/null +++ b/lib/__tests__/batchedLoader.test.ts @@ -0,0 +1,187 @@ +import { Cooldown, createBatchedLoader } from '@/lib/batchedLoader'; + +const TTL = 60_000; +const MISS_TTL = 300_000; +const ERROR_TTL = 15_000; +const MAX_STALE = 600_000; + +let clock = 0; +const rateLimited = Object.assign(new Error('429'), { rateLimited: true }); + +const setup = (options: { cooldown?: Cooldown; chunkSize?: number } = {}) => { + const fetchChunk = jest.fn(async (keys: string[]) => { + const values = new Map(); + for (const key of keys) if (!key.startsWith('missing')) values.set(key, key.length); + return values; + }); + const loader = createBatchedLoader({ + chunk: keys => { + const size = options.chunkSize ?? 25; + const chunks: string[][] = []; + for (let i = 0; i < keys.length; i += size) chunks.push(keys.slice(i, i + size)); + return chunks; + }, + fetchChunk, + ttlMs: TTL, + missTtlMs: MISS_TTL, + errorTtlMs: ERROR_TTL, + maxStaleMs: MAX_STALE, + cooldownMs: error => ((error as { rateLimited?: boolean }).rateLimited ? 60_000 : undefined), + cooldown: options.cooldown, + now: () => clock, + }); + return { loader, fetchChunk }; +}; + +beforeEach(() => { + clock = 1_000_000; +}); + +describe('createBatchedLoader', () => { + it('answers lookups started in the same tick with one request', async () => { + const { loader, fetchChunk } = setup(); + + const [a, b, c] = await Promise.all([ + loader.load(['ETH']), + loader.load(['BNB', 'ETH']), + loader.load(['FUSE']), + ]); + + expect(fetchChunk).toHaveBeenCalledTimes(1); + expect(fetchChunk).toHaveBeenCalledWith(['ETH', 'BNB', 'FUSE']); + expect(a).toEqual(new Map([['ETH', 3]])); + expect(b).toEqual( + new Map([ + ['BNB', 3], + ['ETH', 3], + ]), + ); + expect(c).toEqual(new Map([['FUSE', 4]])); + }); + + it('splits one flush into the chunks the upstream accepts', async () => { + const { loader, fetchChunk } = setup({ chunkSize: 2 }); + + await loader.load(['a', 'b', 'c', 'd', 'e']); + + expect(fetchChunk.mock.calls).toEqual([[['a', 'b']], [['c', 'd']], [['e']]]); + }); + + it('joins a request already in flight instead of sending another', async () => { + const { loader, fetchChunk } = setup(); + let respond: (values: Map) => void = () => {}; + fetchChunk.mockImplementationOnce(() => new Promise(resolve => (respond = resolve))); + + const first = loader.load(['ETH']); + await new Promise(resolve => setTimeout(resolve, 0)); // the first request is now out + expect(fetchChunk).toHaveBeenCalledTimes(1); + const second = loader.load(['ETH']); + respond(new Map([['ETH', 3]])); + + await expect(Promise.all([first, second])).resolves.toEqual([ + new Map([['ETH', 3]]), + new Map([['ETH', 3]]), + ]); + expect(fetchChunk).toHaveBeenCalledTimes(1); + }); + + it('serves a value from cache until its TTL runs out', async () => { + const { loader, fetchChunk } = setup(); + + await loader.load(['ETH']); + clock += TTL - 1; + await loader.load(['ETH']); + expect(fetchChunk).toHaveBeenCalledTimes(1); + + clock += 1; + await loader.load(['ETH']); + expect(fetchChunk).toHaveBeenCalledTimes(2); + }); + + it('remembers a key upstream had no value for', async () => { + const { loader, fetchChunk } = setup(); + + await expect(loader.load(['missing-token'])).resolves.toEqual(new Map()); + clock += MISS_TTL - 1; + await expect(loader.load(['missing-token'])).resolves.toEqual(new Map()); + expect(fetchChunk).toHaveBeenCalledTimes(1); + + clock += 1; + await loader.load(['missing-token']); + expect(fetchChunk).toHaveBeenCalledTimes(2); + }); + + it('keeps serving the last value while a refresh fails, then retries after the error TTL', async () => { + const { loader, fetchChunk } = setup(); + await loader.load(['ETH']); + + clock += TTL; + fetchChunk.mockRejectedValueOnce(new Error('network down')); + await expect(loader.load(['ETH'])).resolves.toEqual(new Map([['ETH', 3]])); + expect(fetchChunk).toHaveBeenCalledTimes(2); + + clock += ERROR_TTL - 1; + await loader.load(['ETH']); + expect(fetchChunk).toHaveBeenCalledTimes(2); + + clock += 1; + await loader.load(['ETH']); + expect(fetchChunk).toHaveBeenCalledTimes(3); + }); + + it('drops a value once it is older than the stale limit', async () => { + const { loader, fetchChunk } = setup(); + await loader.load(['ETH']); + + fetchChunk.mockRejectedValue(new Error('network down')); + clock += MAX_STALE + 1; + + await expect(loader.load(['ETH'])).resolves.toEqual(new Map()); + }); + + it('pauses every loader sharing a cooldown after a rate limit', async () => { + const cooldown = { until: 0 }; + const symbols = setup({ cooldown }); + const addresses = setup({ cooldown }); + symbols.fetchChunk.mockRejectedValueOnce(rateLimited); + + await expect(symbols.loader.load(['ETH'])).resolves.toEqual(new Map()); + await expect(addresses.loader.load(['8453:0xabc'])).resolves.toEqual(new Map()); + await symbols.loader.load(['BNB']); + expect(symbols.fetchChunk).toHaveBeenCalledTimes(1); + expect(addresses.fetchChunk).not.toHaveBeenCalled(); + + clock += 60_000; + await addresses.loader.load(['8453:0xabc']); + expect(addresses.fetchChunk).toHaveBeenCalledTimes(1); + }); + + it('never rejects, even when splitting the keys throws', async () => { + const loader = createBatchedLoader({ + chunk: () => { + throw new Error('bad chunking'); + }, + fetchChunk: jest.fn(), + ttlMs: TTL, + missTtlMs: MISS_TTL, + errorTtlMs: ERROR_TTL, + maxStaleMs: MAX_STALE, + now: () => clock, + }); + + await expect(loader.load(['ETH'])).resolves.toEqual(new Map()); + }); + + it('forgets cached values and lifts the pause on clear', async () => { + const cooldown = { until: 0 }; + const { loader, fetchChunk } = setup({ cooldown }); + await loader.load(['ETH']); + cooldown.until = clock + 60_000; + + loader.clear(); + await loader.load(['ETH']); + + expect(cooldown.until).toBe(0); + expect(fetchChunk).toHaveBeenCalledTimes(2); + }); +}); diff --git a/lib/api.ts b/lib/api.ts index 8c47314da..a0f4351bf 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -7,9 +7,12 @@ import { fuse } from 'viem/chains'; import { ALCHEMY_NETWORKS, ALCHEMY_PRICE_BATCH_SIZE, + ALCHEMY_PRICE_MAX_NETWORKS, ALCHEMY_PRICES_URL, + ALCHEMY_REQUEST_TIMEOUT_MS, } from '@/constants/alchemy'; import { MOCK_REWARDS_USER_DATA, MOCK_TIER_BENEFITS } from '@/constants/rewards'; +import { Cooldown, createBatchedLoader } from '@/lib/batchedLoader'; import { fetchTokenTransferWithFallback } from '@/lib/data-source'; import { fetchWithTimeout } from '@/lib/fetchWithTimeout'; import { toTransfiError } from '@/lib/transfiErrors'; @@ -444,74 +447,183 @@ export const fetchTokenTransfer = async ({ }); }; -export const fetchTokenPriceUsd = async (token: string) => { - // externalAxios (not the global axios): Alchemy 401s when the Solid JWT is - // attached, which zeroes out every price on native builds. - const response = await externalAxios.get( - `${ALCHEMY_PRICES_URL}/by-symbol?symbols=${token}`, - ); - return response?.data?.data[0]?.prices[0]?.value; +/** + * How long a price from Alchemy is shared by every caller before it's asked for + * again. Alchemy refreshes its prices about once a minute. + */ +const ALCHEMY_PRICE_TTL_MS = 60_000; +/** A token Alchemy has no price for rarely gains one within minutes. */ +const ALCHEMY_PRICE_MISS_TTL_MS = 5 * 60_000; +const ALCHEMY_PRICE_ERROR_TTL_MS = 15_000; +/** Past this a price is dropped rather than shown, even when it can't be refreshed. */ +const ALCHEMY_PRICE_MAX_STALE_MS = 10 * 60_000; +/** Pause on a 429 without a usable Retry-After, and the cap on one that has it. */ +const ALCHEMY_PRICE_RATE_LIMIT_PAUSE_MS = 60_000; +const ALCHEMY_PRICE_MAX_RATE_LIMIT_PAUSE_MS = 15 * 60_000; + +/** The USD quote from a Prices API entry, when it's a usable positive number. */ +const usdPrice = (prices: { currency: string; value: string }[] | undefined) => { + const value = Number(prices?.find(price => price.currency?.toLowerCase() === 'usd')?.value); + return Number.isFinite(value) && value > 0 ? value : undefined; +}; + +/** + * Both Prices endpoints draw on one token_price quota ("Your payg app has + * exceeded its limit of 10000 token_price requests per 1 hours"), so a 429 from + * either pauses both. Every request made while the quota is spent is refused + * anyway, and each refusal was also being reported to Sentry. + */ +const alchemyPriceCooldown: Cooldown = { until: 0 }; + +const rateLimitPauseMs = (error: unknown) => { + const response = (error as { response?: { status?: number; headers?: Record } }) + ?.response; + if (response?.status !== 429) return undefined; + const retryAfterMs = Number(response.headers?.['retry-after']) * 1000; + return Number.isFinite(retryAfterMs) && retryAfterMs > 0 + ? Math.min(retryAfterMs, ALCHEMY_PRICE_MAX_RATE_LIMIT_PAUSE_MS) + : ALCHEMY_PRICE_RATE_LIMIT_PAUSE_MS; +}; + +const alchemyPriceCaching = { + ttlMs: ALCHEMY_PRICE_TTL_MS, + missTtlMs: ALCHEMY_PRICE_MISS_TTL_MS, + errorTtlMs: ALCHEMY_PRICE_ERROR_TTL_MS, + maxStaleMs: ALCHEMY_PRICE_MAX_STALE_MS, + cooldownMs: rateLimitPauseMs, + cooldown: alchemyPriceCooldown, +}; + +/** Keyed by upper-cased symbol: Alchemy upper-cases the symbols it echoes back. */ +const alchemyPricesBySymbol = createBatchedLoader({ + ...alchemyPriceCaching, + chunk: symbols => { + const chunks: string[][] = []; + for (let i = 0; i < symbols.length; i += ALCHEMY_PRICE_BATCH_SIZE) { + chunks.push(symbols.slice(i, i + ALCHEMY_PRICE_BATCH_SIZE)); + } + return chunks; + }, + fetchChunk: async symbols => { + // externalAxios (not the global axios): Alchemy 401s when the Solid JWT is + // attached, which zeroes out every price on native builds. + const response = await externalAxios.get( + `${ALCHEMY_PRICES_URL}/by-symbol?${symbols.map(s => `symbols=${encodeURIComponent(s)}`).join('&')}`, + { timeout: ALCHEMY_REQUEST_TIMEOUT_MS }, + ); + const prices = new Map(); + for (const entry of response.data?.data ?? []) { + const price = usdPrice(entry.prices); + if (entry.symbol && price !== undefined) prices.set(entry.symbol.toUpperCase(), price); + } + return prices; + }, +}); + +/** Keyed by `${chainId}:${lowercased address}`. */ +const alchemyPricesByAddress = createBatchedLoader({ + ...alchemyPriceCaching, + // First fit, so tokens keep their order and each request stays within both + // the address and the network cap. + chunk: keys => { + const chunks: { keys: string[]; networks: Set }[] = []; + for (const key of keys) { + const network = ALCHEMY_NETWORKS[Number(key.split(':')[0])]; + const chunk = chunks.find( + c => + c.keys.length < ALCHEMY_PRICE_BATCH_SIZE && + (c.networks.has(network) || c.networks.size < ALCHEMY_PRICE_MAX_NETWORKS), + ); + if (chunk) { + chunk.keys.push(key); + chunk.networks.add(network); + } else { + chunks.push({ keys: [key], networks: new Set([network]) }); + } + } + return chunks.map(c => c.keys); + }, + fetchChunk: async keys => { + const tokens = keys.map(key => { + const [chainId, address] = key.split(':'); + return { chainId: Number(chainId), network: ALCHEMY_NETWORKS[Number(chainId)], address }; + }); + const response = await externalAxios.post( + `${ALCHEMY_PRICES_URL}/by-address`, + { addresses: tokens.map(({ network, address }) => ({ network, address })) }, + { timeout: ALCHEMY_REQUEST_TIMEOUT_MS }, + ); + // Alchemy echoes the network slug back, not the chain id, so map the + // response entries onto the request to recover the chain id. + const chainIdByNetwork = new Map(tokens.map(({ network, chainId }) => [network, chainId])); + const prices = new Map(); + for (const entry of response.data?.data ?? []) { + const chainId = chainIdByNetwork.get(entry.network); + const price = usdPrice(entry.prices); + if (chainId === undefined || !entry.address || price === undefined) continue; + prices.set(`${chainId}:${entry.address.toLowerCase()}`, price); + } + return prices; + }, +}); + +/** Drops every cached Alchemy price and lifts any rate-limit pause. */ +export const clearAlchemyPriceCache = () => { + alchemyPricesBySymbol.clear(); + alchemyPricesByAddress.clear(); +}; + +/** + * USD prices by symbol from Alchemy's Prices API, keyed by the symbols as passed. + * + * Lookups started in the same tick share requests of up to 25 symbols, and a + * price is reused by every caller for a minute. Never throws: a symbol without + * a price, or one Alchemy couldn't be asked about (rate-limited, down), is left + * out so the caller's next price source gets its turn. + */ +export const fetchTokenPricesBySymbol = async ( + symbols: string[], +): Promise> => { + const wanted = [...new Set(symbols.filter(Boolean))]; + if (wanted.length === 0) return {}; + const prices = await alchemyPricesBySymbol.load(wanted.map(symbol => symbol.toUpperCase())); + const bySymbol: Record = {}; + for (const symbol of wanted) { + const price = prices.get(symbol.toUpperCase()); + if (price !== undefined) bySymbol[symbol] = price; + } + return bySymbol; +}; + +/** USD price of one symbol, as a string; undefined when Alchemy has none to give. */ +export const fetchTokenPriceUsd = async (token: string): Promise => { + const price = (await fetchTokenPricesBySymbol([token]))[token]; + return price === undefined ? undefined : String(price); }; /** * USD prices for ERC-20s from Alchemy's Prices API, keyed by * `${chainId}:${lowercased address}`. * - * Preferred over {@link fetchTokenPriceUsd} for ERC-20s: a contract address - * identifies a token exactly, where a symbol does not (every chain has its own - * "USDC", and plenty of scam tokens borrow a real ticker), and one POST covers - * a whole batch instead of a request per symbol. Alchemy's own token balances - * carry no price, so without this every Alchemy-sourced ERC-20 arrives at - * quoteRate 0. + * Preferred over {@link fetchTokenPricesBySymbol} for ERC-20s: a contract + * address identifies a token exactly, where a symbol does not (every chain has + * its own "USDC", and plenty of scam tokens borrow a real ticker). Alchemy's + * own token balances carry no price, so without this every Alchemy-sourced + * ERC-20 arrives at quoteRate 0. * - * Never throws: a failed batch resolves to no prices for that batch so the - * remaining price sources still get their turn. + * Batched and cached like the symbol lookup, within the endpoint's limits of + * 25 addresses and 3 networks per request. Never throws: a failed batch + * resolves to no prices for that batch so the remaining price sources still + * get their turn. */ export const fetchTokenPricesByAddress = async ( tokens: { chainId: number; address: string }[], ): Promise> => { - const pairs = [ - ...new Map( - tokens - .filter(({ chainId, address }) => !!ALCHEMY_NETWORKS[chainId] && !!address) - .map(({ chainId, address }) => [ - `${chainId}:${address.toLowerCase()}`, - { chainId, network: ALCHEMY_NETWORKS[chainId], address: address.toLowerCase() }, - ]), - ).values(), - ]; - if (pairs.length === 0) return {}; - - const batches: (typeof pairs)[] = []; - for (let i = 0; i < pairs.length; i += ALCHEMY_PRICE_BATCH_SIZE) { - batches.push(pairs.slice(i, i + ALCHEMY_PRICE_BATCH_SIZE)); - } - - const responses = await Promise.allSettled( - batches.map(batch => - externalAxios.post(`${ALCHEMY_PRICES_URL}/by-address`, { - addresses: batch.map(({ network, address }) => ({ network, address })), - }), - ), - ); - - const prices: Record = {}; - responses.forEach((response, i) => { - if (response.status !== 'fulfilled') return; - // Alchemy echoes the network slug back, not the chain id, so map the - // response entries onto the batch we sent to recover the chain id. - const chainIdByNetwork = new Map(batches[i].map(({ network, chainId }) => [network, chainId])); - for (const entry of response.value.data?.data ?? []) { - const chainId = chainIdByNetwork.get(entry.network); - const value = Number(entry.prices?.find(price => price.currency === 'usd')?.value); - if (chainId === undefined || !entry.address || !Number.isFinite(value) || value <= 0) { - continue; - } - prices[`${chainId}:${entry.address.toLowerCase()}`] = value; - } - }); - - return prices; + const keys = tokens + .filter(({ chainId, address }) => !!ALCHEMY_NETWORKS[chainId] && !!address) + .map(({ chainId, address }) => `${chainId}:${address.toLowerCase()}`); + if (keys.length === 0) return {}; + return Object.fromEntries(await alchemyPricesByAddress.load(keys)); }; export const createKycLink = async ( diff --git a/lib/batchedLoader.ts b/lib/batchedLoader.ts new file mode 100644 index 000000000..dabf07701 --- /dev/null +++ b/lib/batchedLoader.ts @@ -0,0 +1,128 @@ +/** + * Merges lookups started in the same tick into as few upstream requests as the + * API allows, and caches what comes back so repeat lookups stay local. + * + * Built for Alchemy's Prices API, whose quota is counted in requests — 10,000 + * an hour per app on Pay As You Go, shared by every client of the app — while a + * single request carries up to 25 tokens at the same 40 CU. Asking for one + * price per request with nothing kept spent that quota several times over on + * every balance refresh, and 429'd everyone once it ran out. + */ + +type Entry = { + /** Last value upstream returned. Kept past its TTL, up to `maxStaleMs`. */ + value?: V; + /** When `value` was fetched. */ + fetchedAt?: number; + /** Until then the key is answered from the cache without asking upstream. */ + freshUntil: number; +}; + +/** A pause on upstream calls, shareable between loaders drawing on one quota. */ +export type Cooldown = { until: number }; + +export type BatchedLoaderOptions = { + /** Splits one flush's keys into groups that each fit in a single request. */ + chunk: (keys: string[]) => string[][]; + /** Fetches one group. Keys absent from the result have no value upstream. */ + fetchChunk: (keys: string[]) => Promise>; + /** How long a fetched value is reused before asking upstream again. */ + ttlMs: number; + /** How long to wait before asking again about a key upstream had no value for. */ + missTtlMs: number; + /** How long to wait before retrying keys whose request failed. */ + errorTtlMs: number; + /** How long a value stays usable past its TTL when refreshing it fails or is skipped. */ + maxStaleMs: number; + /** + * How long a failure should stop every upstream call, if at all. Meant for + * rate limits: once the quota is spent, each further request until it refills + * is refused anyway. + */ + cooldownMs?: (error: unknown) => number | undefined; + /** Pass the same object to loaders that share a quota so one 429 pauses them all. */ + cooldown?: Cooldown; + now?: () => number; +}; + +export type BatchedLoader = { + /** Values for the keys that have one. Never rejects. */ + load: (keys: string[]) => Promise>; + /** Forgets every cached value and lifts any pause. */ + clear: () => void; +}; + +export const createBatchedLoader = (options: BatchedLoaderOptions): BatchedLoader => { + const now = options.now ?? (() => Date.now()); + const cooldown = options.cooldown ?? { until: 0 }; + const cache = new Map>(); + // Keys queued for, or waiting on, a request that hasn't settled yet. + const pending = new Map>(); + let queue: string[] = []; + let scheduled: Promise | undefined; + + const settle = (keys: string[], freshForMs: number, values?: Map) => { + const at = now(); + for (const key of keys) { + const value = values?.get(key); + cache.set( + key, + value === undefined + ? { ...cache.get(key), freshUntil: at + freshForMs } + : { value, fetchedAt: at, freshUntil: at + options.ttlMs }, + ); + } + }; + + const fetchGroup = async (keys: string[]) => { + try { + settle(keys, options.missTtlMs, await options.fetchChunk(keys)); + } catch (error) { + const pauseMs = options.cooldownMs?.(error); + if (pauseMs) cooldown.until = Math.max(cooldown.until, now() + pauseMs); + settle(keys, options.errorTtlMs); + } + }; + + const flush = async () => { + const keys = queue; + queue = []; + scheduled = undefined; + try { + await Promise.all(options.chunk(keys).map(fetchGroup)); + } finally { + for (const key of keys) pending.delete(key); + } + }; + + const load = async (keys: string[]) => { + const unique = [...new Set(keys)]; + const at = now(); + for (const key of unique) { + if (pending.has(key) || at < cooldown.until) continue; + if ((cache.get(key)?.freshUntil ?? 0) > at) continue; + queue.push(key); + // Wait out the current tick so every lookup started in it rides one flush. + scheduled ??= new Promise(resolve => setTimeout(resolve, 0)).then(flush); + pending.set(key, scheduled); + } + await Promise.allSettled(unique.map(key => pending.get(key))); + + const values = new Map(); + const readAt = now(); + for (const key of unique) { + const entry = cache.get(key); + if (entry?.value !== undefined && readAt - (entry.fetchedAt ?? 0) <= options.maxStaleMs) { + values.set(key, entry.value); + } + } + return values; + }; + + const clear = () => { + cache.clear(); + cooldown.until = 0; + }; + + return { load, clear }; +}; diff --git a/lib/wagmi.ts b/lib/wagmi.ts index ba909cfb0..924396c68 100644 --- a/lib/wagmi.ts +++ b/lib/wagmi.ts @@ -2,15 +2,7 @@ import { Platform } from 'react-native'; import { Chain, createPublicClient } from 'viem'; import { createConfig, http } from 'wagmi'; import { getWalletClient } from 'wagmi/actions'; -import { - arbitrum, - base, - baseSepolia, - bsc, - fuse, - mainnet, - polygon, -} from 'wagmi/chains'; +import { arbitrum, base, baseSepolia, bsc, fuse, mainnet, polygon } from 'wagmi/chains'; import { EXPO_PUBLIC_ALCHEMY_API_KEY } from './config'; @@ -42,12 +34,32 @@ const transports: Record> = { [bsc.id]: http(rpcUrls[bsc.id]), }; -export const publicClient = (chainId: number) => +const createChainClient = (chainId: number) => createPublicClient({ chain: chains.find(chain => chain.id === chainId), transport: http(rpcUrls[chainId]), + // Contract reads made in the same tick go out as one Multicall3 eth_call, + // billed by Alchemy as a single 26 CU call however many reads it carries. + // viem only batches plain reads (no account, value or gas) and falls back + // to a normal eth_call on a chain without Multicall3. + batch: { multicall: true }, }); +const publicClients = new Map>(); + +/** + * One client per chain. viem batches per client, so the fresh client every + * call used to build could never share a request with anything. + */ +export const publicClient = (chainId: number) => { + let client = publicClients.get(chainId); + if (!client) { + client = createChainClient(chainId); + publicClients.set(chainId, client); + } + return client; +}; + export const getWallet = (chainId: number) => { return getWalletClient(config, { chainId }); }; From fd12c42dbb17c6366b07f3a7d7ee5bcba4a8dbca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:18:51 +0000 Subject: [PATCH 2/4] feat(prices): ask the backend's shared price cache first, fall back to Alchemy Prices now come from POST /accounts/v1/prices, which serves one cache shared by every user and every pod. So the app key's Alchemy token_price quota no longer scales with the number of open apps. The backend is only an optimisation for the app. On any failure it asks Alchemy directly with its own key, exactly as before, and skips the backend for five minutes. Failures include a network error, a timeout, an older backend without the route (404), an expired session (401), the per-user rate limit (429), a token the backend can't price right now (503), or a reply that isn't the expected shape. That keeps every combination safe: - an app build or OTA that ships before the backend route is deployed - a backend rollback - web and native (cookie or Bearer auth) Old builds keep calling Alchemy directly. Symbols and addresses the route's validation would reject are left out of the request, so one malformed token can't fail the lookup for the rest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UMuZxhszcH7Kc5s7dc2NNq --- lib/__tests__/alchemyTokenPrices.test.ts | 146 ++++++++++++++++++++++- lib/api.ts | 115 ++++++++++++++++-- 2 files changed, 246 insertions(+), 15 deletions(-) diff --git a/lib/__tests__/alchemyTokenPrices.test.ts b/lib/__tests__/alchemyTokenPrices.test.ts index d06051af2..be3e17925 100644 --- a/lib/__tests__/alchemyTokenPrices.test.ts +++ b/lib/__tests__/alchemyTokenPrices.test.ts @@ -6,10 +6,11 @@ jest.mock('@/constants/rewards', () => ({ MOCK_REWARDS_USER_DATA: {}, MOCK_TIER_BENEFITS: {}, })); -// Reached for the JWT the price lookup deliberately does not send; the store -// itself sits on native MMKV. +// Read for the JWT the backend lookup sends (and the Alchemy one deliberately +// does not); the store itself sits on native MMKV. +const mockUsers: { selected: boolean; tokens?: { accessToken: string } }[] = []; jest.mock('@/store/useUserStore', () => ({ - useUserStore: { getState: () => ({ users: [] }) }, + useUserStore: { getState: () => ({ users: mockUsers }) }, })); jest.mock('@sentry/react-native', () => ({ addBreadcrumb: jest.fn(), @@ -66,9 +67,26 @@ const rateLimited = () => response: { status: 429, headers: {} }, }); +/** + * The backend route (`POST /accounts/v1/prices`), which the lookups try first. + * It is unreachable unless a test says otherwise, so the Alchemy tests below + * exercise the fallback every app has when the backend can't answer. + */ +const fetchMock = jest.fn(); +const backendAnswers = (body: unknown, status = 200) => + fetchMock.mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + }); + beforeEach(() => { post.mockReset(); get.mockReset(); + fetchMock.mockReset(); + fetchMock.mockRejectedValue(new TypeError('Network request failed')); + global.fetch = fetchMock as unknown as typeof fetch; + mockUsers.length = 0; // Prices are shared module-wide for a minute; start every test cold. clearAlchemyPriceCache(); }); @@ -334,3 +352,125 @@ describe('fetchTokenPricesByAddress', () => { expect(post).toHaveBeenCalledTimes(1); }); }); + +describe('prices from the backend', () => { + it('asks the backend first and leaves Alchemy alone when it answers', async () => { + backendAnswers({ symbols: { ETH: 2658.09, BNB: 765.87 }, tokens: {} }); + + const [eth, bnb] = await Promise.all([fetchTokenPriceUsd('ETH'), fetchTokenPriceUsd('bnb')]); + + expect([eth, bnb]).toEqual(['2658.09', '765.87']); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toMatch(/\/accounts\/v1\/prices$/); + expect(init.method).toBe('POST'); + expect(init.credentials).toBe('include'); + expect(JSON.parse(init.body as string)).toEqual({ symbols: ['ETH', 'BNB'] }); + expect(get).not.toHaveBeenCalled(); + }); + + it('sends the signed-in user’s token on native', async () => { + mockUsers.push({ selected: true, tokens: { accessToken: 'jwt-1' } }); + backendAnswers({ symbols: { ETH: 2658.09 }, tokens: {} }); + + await fetchTokenPriceUsd('ETH'); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record).Authorization).toBe('Bearer jwt-1'); + }); + + it('prices tokens by address through the backend with the same keys', async () => { + backendAnswers({ symbols: {}, tokens: { [`8453:${USDC_BASE.toLowerCase()}`]: 1.0001 } }); + + await expect( + fetchTokenPricesByAddress([{ chainId: 8453, address: USDC_BASE }]), + ).resolves.toEqual({ + [`8453:${USDC_BASE.toLowerCase()}`]: 1.0001, + }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ + tokens: [{ chainId: 8453, address: USDC_BASE.toLowerCase() }], + }); + expect(post).not.toHaveBeenCalled(); + }); + + it('shares one cached answer instead of asking the backend again', async () => { + backendAnswers({ symbols: { ETH: 2658.09 }, tokens: {} }); + + await fetchTokenPriceUsd('ETH'); + await fetchTokenPriceUsd('ETH'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('takes a symbol the backend has no price for as a miss, without asking Alchemy', async () => { + backendAnswers({ symbols: {}, tokens: {} }); + + await expect(fetchTokenPriceUsd('NOTAREALTOKEN')).resolves.toBeUndefined(); + expect(get).not.toHaveBeenCalled(); + }); + + it.each([ + ['is unreachable', () => fetchMock.mockRejectedValue(new TypeError('Network request failed'))], + [ + 'has no such route yet (older backend)', + () => backendAnswers({ message: 'Cannot POST' }, 404), + ], + ['rejects the session', () => backendAnswers({ message: 'Unauthorized' }, 401)], + // What the route answers when it can't reach Alchemy for a token it has no price for. + ['cannot price right now', () => backendAnswers({ message: 'No price available' }, 503)], + ['rate-limits this user', () => backendAnswers({ message: 'Too Many Requests' }, 429)], + ['answers with something unexpected', () => backendAnswers('proxy error')], + ])( + 'falls back to Alchemy when the backend %s, then skips the backend for a while', + async (_, setup) => { + setup(); + get.mockResolvedValue({ + data: { data: [bySymbol('ETH', '2658.09'), bySymbol('BNB', '765.87')] }, + }); + + await expect(fetchTokenPriceUsd('ETH')).resolves.toBe('2658.09'); + await expect(fetchTokenPriceUsd('BNB')).resolves.toBe('765.87'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(get).toHaveBeenCalledTimes(2); + }, + ); + + it('asks the backend again once the pause is over', async () => { + const now = jest.spyOn(Date, 'now'); + try { + now.mockReturnValue(1_000_000); + get.mockResolvedValue({ data: { data: [bySymbol('ETH', '2658.09')] } }); + await fetchTokenPriceUsd('ETH'); // backend unreachable: Alchemy answers + + now.mockReturnValue(1_000_000 + 5 * 60_000); + backendAnswers({ symbols: { BNB: 765.87 }, tokens: {} }); + await expect(fetchTokenPriceUsd('BNB')).resolves.toBe('765.87'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(get).toHaveBeenCalledTimes(1); + } finally { + now.mockRestore(); + } + }); + + it('never sends the backend a symbol or address its validation would reject', async () => { + backendAnswers({ symbols: {}, tokens: {} }); + + await fetchTokenPricesBySymbol(['ETH', 'X'.repeat(65)]); + await fetchTokenPricesByAddress([ + { chainId: 1, address: 'not-an-address' }, + { chainId: 1, address: USDC_ETHEREUM }, + ]); + + const bodies = fetchMock.mock.calls.map( + ([, init]) => JSON.parse((init as RequestInit).body as string) as unknown, + ); + expect(bodies).toEqual([ + { symbols: ['ETH'] }, + { tokens: [{ chainId: 1, address: USDC_ETHEREUM.toLowerCase() }] }, + ]); + }); +}); diff --git a/lib/api.ts b/lib/api.ts index a0f4351bf..af2fb2c42 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -494,6 +494,86 @@ const alchemyPriceCaching = { cooldown: alchemyPriceCooldown, }; +/** Longer than the backend's own Alchemy timeout (4s), so it gives up first. */ +const BACKEND_PRICES_TIMEOUT_MS = 6_000; +const BACKEND_PRICES_RETRY_MS = 5 * 60_000; +/** The route's per-list limits; anything past them would fail the whole request. */ +const BACKEND_PRICES_MAX_SYMBOL_LENGTH = 64; +const ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/; +let backendPricesPausedUntil = 0; + +type BackendPrices = { symbols: Record; tokens: Record }; + +const positivePrices = (value: unknown): Record | undefined => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, number] => + typeof entry[1] === 'number' && Number.isFinite(entry[1]) && entry[1] > 0, + ), + ); +}; + +/** + * Prices from the backend, or undefined when it couldn't answer. Never throws. + * + * The backend serves prices from one cache shared by every user + * (POST /accounts/v1/prices), so the app asks it first and every open copy no + * longer spends the app key's Alchemy quota. When it can't answer — a backend + * released before the route existed, an outage, an expired session, a token it + * can't price right now (503) — the app asks Alchemy itself as before, and + * skips the backend for a few minutes so each lookup doesn't wait on it first. + */ +const fetchBackendPrices = async (body: { + symbols?: string[]; + tokens?: { chainId: number; address: string }[]; +}): Promise => { + if (Date.now() < backendPricesPausedUntil) return undefined; + try { + const jwt = getJWTToken(); + const response = await fetchWithTimeout( + `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/prices`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + credentials: 'include', + body: JSON.stringify(body), + }, + BACKEND_PRICES_TIMEOUT_MS, + ); + if (!response.ok) throw new Error(`Backend prices responded ${response.status}`); + const data = (await response.json()) as { symbols?: unknown; tokens?: unknown }; + const symbols = positivePrices(data?.symbols); + const tokens = positivePrices(data?.tokens); + // Anything but the expected shape is a failure, not "no prices": a miss is + // remembered for minutes, so a misrouted reply mustn't be taken for one. + if (!symbols || !tokens) throw new Error('Unexpected backend prices response'); + return { symbols, tokens }; + } catch { + backendPricesPausedUntil = Date.now() + BACKEND_PRICES_RETRY_MS; + return undefined; + } +}; + +const fetchAlchemyPricesBySymbol = async (symbols: string[]) => { + // externalAxios (not the global axios): Alchemy 401s when the Solid JWT is + // attached, which zeroes out every price on native builds. + const response = await externalAxios.get( + `${ALCHEMY_PRICES_URL}/by-symbol?${symbols.map(s => `symbols=${encodeURIComponent(s)}`).join('&')}`, + { timeout: ALCHEMY_REQUEST_TIMEOUT_MS }, + ); + const prices = new Map(); + for (const entry of response.data?.data ?? []) { + const price = usdPrice(entry.prices); + if (entry.symbol && price !== undefined) prices.set(entry.symbol.toUpperCase(), price); + } + return prices; +}; + /** Keyed by upper-cased symbol: Alchemy upper-cases the symbols it echoes back. */ const alchemyPricesBySymbol = createBatchedLoader({ ...alchemyPriceCaching, @@ -505,18 +585,18 @@ const alchemyPricesBySymbol = createBatchedLoader({ return chunks; }, fetchChunk: async symbols => { - // externalAxios (not the global axios): Alchemy 401s when the Solid JWT is - // attached, which zeroes out every price on native builds. - const response = await externalAxios.get( - `${ALCHEMY_PRICES_URL}/by-symbol?${symbols.map(s => `symbols=${encodeURIComponent(s)}`).join('&')}`, - { timeout: ALCHEMY_REQUEST_TIMEOUT_MS }, - ); - const prices = new Map(); - for (const entry of response.data?.data ?? []) { - const price = usdPrice(entry.prices); - if (entry.symbol && price !== undefined) prices.set(entry.symbol.toUpperCase(), price); + // A symbol past the route's limit isn't a real ticker; leaving it out keeps + // it from failing the lookup for the others. + const sendable = symbols.filter(s => s.length <= BACKEND_PRICES_MAX_SYMBOL_LENGTH); + const fromBackend = sendable.length + ? await fetchBackendPrices({ symbols: sendable }) + : undefined; + if (fromBackend) { + return new Map( + Object.entries(fromBackend.symbols).map(([symbol, price]) => [symbol.toUpperCase(), price]), + ); } - return prices; + return fetchAlchemyPricesBySymbol(symbols); }, }); @@ -548,6 +628,16 @@ const alchemyPricesByAddress = createBatchedLoader({ const [chainId, address] = key.split(':'); return { chainId: Number(chainId), network: ALCHEMY_NETWORKS[Number(chainId)], address }; }); + + const sendable = tokens + .filter(({ address }) => ADDRESS_PATTERN.test(address)) + .map(({ chainId, address }) => ({ chainId, address })); + const fromBackend = sendable.length + ? await fetchBackendPrices({ tokens: sendable }) + : undefined; + // Keyed `${chainId}:${lowercased address}` by the backend too. + if (fromBackend) return new Map(Object.entries(fromBackend.tokens)); + const response = await externalAxios.post( `${ALCHEMY_PRICES_URL}/by-address`, { addresses: tokens.map(({ network, address }) => ({ network, address })) }, @@ -567,10 +657,11 @@ const alchemyPricesByAddress = createBatchedLoader({ }, }); -/** Drops every cached Alchemy price and lifts any rate-limit pause. */ +/** Drops every cached price and lifts any pause on the backend or on Alchemy. */ export const clearAlchemyPriceCache = () => { alchemyPricesBySymbol.clear(); alchemyPricesByAddress.clear(); + backendPricesPausedUntil = 0; }; /** From 2274ce527afd21e2b67f428106087eec7f47501c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:59:01 +0000 Subject: [PATCH 3/4] fix(prices): send the backend only the chains its price route covers The backend's price route leaves out tokens on chains it has no Alchemy network for, and the app takes a token left out as having no price and remembers that for five minutes. A chain added to ALCHEMY_NETWORKS (which an OTA can do) before the backend knows it would lose its prices that way. Address lookups are now chunked by whether the backend covers the chain (BACKEND_PRICE_CHAIN_IDS, mirroring its ALCHEMY_PRICE_NETWORKS), and tokens on any other chain are priced from Alchemy directly, as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UMuZxhszcH7Kc5s7dc2NNq --- constants/alchemy.ts | 14 +++++++++ lib/__tests__/alchemyTokenPrices.test.ts | 36 ++++++++++++++++++++++++ lib/api.ts | 22 ++++++++++----- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/constants/alchemy.ts b/constants/alchemy.ts index f37c1b368..b0e367b6c 100644 --- a/constants/alchemy.ts +++ b/constants/alchemy.ts @@ -39,6 +39,20 @@ export const ALCHEMY_NETWORKS: Record = { [bsc.id]: 'bnb-mainnet', }; +/** + * Chains the backend's price route (`POST /accounts/v1/prices`) prices by + * address, its own `ALCHEMY_PRICE_NETWORKS`. The route leaves out a chain it + * doesn't know, which the app would take for "no price", so tokens on a chain + * added to `ALCHEMY_NETWORKS` before the backend has it go to Alchemy directly. + */ +export const BACKEND_PRICE_CHAIN_IDS: ReadonlySet = new Set([ + mainnet.id, + base.id, + polygon.id, + arbitrum.id, + bsc.id, +]); + export const ALCHEMY_PRICES_URL = `https://api.g.alchemy.com/prices/v1/${EXPO_PUBLIC_ALCHEMY_API_KEY}/tokens`; /** diff --git a/lib/__tests__/alchemyTokenPrices.test.ts b/lib/__tests__/alchemyTokenPrices.test.ts index be3e17925..f086e2f89 100644 --- a/lib/__tests__/alchemyTokenPrices.test.ts +++ b/lib/__tests__/alchemyTokenPrices.test.ts @@ -12,6 +12,12 @@ const mockUsers: { selected: boolean; tokens?: { accessToken: string } }[] = []; jest.mock('@/store/useUserStore', () => ({ useUserStore: { getState: () => ({ users: mockUsers }) }, })); +// A chain the app prices by address that the backend doesn't cover yet, as when +// an OTA adds one before the backend ships it. +jest.mock('@/constants/alchemy', () => { + const actual = jest.requireActual('@/constants/alchemy'); + return { ...actual, ALCHEMY_NETWORKS: { ...actual.ALCHEMY_NETWORKS, 10: 'opt-mainnet' } }; +}); jest.mock('@sentry/react-native', () => ({ addBreadcrumb: jest.fn(), captureException: jest.fn(), @@ -395,6 +401,36 @@ describe('prices from the backend', () => { expect(post).not.toHaveBeenCalled(); }); + it('asks Alchemy itself about a chain the backend does not cover yet', async () => { + const USDC_OPTIMISM = '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85'; + backendAnswers({ symbols: {}, tokens: { [`8453:${USDC_BASE.toLowerCase()}`]: 1.0001 } }); + post.mockResolvedValue({ + data: { data: [priced('opt-mainnet', USDC_OPTIMISM.toLowerCase(), '0.9998')] }, + }); + + // Were the Optimism token sent to the backend, it would come back unpriced + // and be remembered for minutes as having no price. + await expect( + fetchTokenPricesByAddress([ + { chainId: 8453, address: USDC_BASE }, + { chainId: 10, address: USDC_OPTIMISM }, + ]), + ).resolves.toEqual({ + [`8453:${USDC_BASE.toLowerCase()}`]: 1.0001, + [`10:${USDC_OPTIMISM.toLowerCase()}`]: 0.9998, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ + tokens: [{ chainId: 8453, address: USDC_BASE.toLowerCase() }], + }); + expect(post).toHaveBeenCalledTimes(1); + expect(post.mock.calls[0][1]).toEqual({ + addresses: [{ network: 'opt-mainnet', address: USDC_OPTIMISM.toLowerCase() }], + }); + }); + it('shares one cached answer instead of asking the backend again', async () => { backendAnswers({ symbols: { ETH: 2658.09 }, tokens: {} }); diff --git a/lib/api.ts b/lib/api.ts index af2fb2c42..528bb9c3f 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -10,6 +10,7 @@ import { ALCHEMY_PRICE_MAX_NETWORKS, ALCHEMY_PRICES_URL, ALCHEMY_REQUEST_TIMEOUT_MS, + BACKEND_PRICE_CHAIN_IDS, } from '@/constants/alchemy'; import { MOCK_REWARDS_USER_DATA, MOCK_TIER_BENEFITS } from '@/constants/rewards'; import { Cooldown, createBatchedLoader } from '@/lib/batchedLoader'; @@ -604,13 +605,17 @@ const alchemyPricesBySymbol = createBatchedLoader({ const alchemyPricesByAddress = createBatchedLoader({ ...alchemyPriceCaching, // First fit, so tokens keep their order and each request stays within both - // the address and the network cap. + // the address and the network cap. Tokens on chains the backend doesn't cover + // never share a request with ones it does, so they can go to Alchemy alone. chunk: keys => { - const chunks: { keys: string[]; networks: Set }[] = []; + const chunks: { keys: string[]; networks: Set; viaBackend: boolean }[] = []; for (const key of keys) { - const network = ALCHEMY_NETWORKS[Number(key.split(':')[0])]; + const chainId = Number(key.split(':')[0]); + const network = ALCHEMY_NETWORKS[chainId]; + const viaBackend = BACKEND_PRICE_CHAIN_IDS.has(chainId); const chunk = chunks.find( c => + c.viaBackend === viaBackend && c.keys.length < ALCHEMY_PRICE_BATCH_SIZE && (c.networks.has(network) || c.networks.size < ALCHEMY_PRICE_MAX_NETWORKS), ); @@ -618,7 +623,7 @@ const alchemyPricesByAddress = createBatchedLoader({ chunk.keys.push(key); chunk.networks.add(network); } else { - chunks.push({ keys: [key], networks: new Set([network]) }); + chunks.push({ keys: [key], networks: new Set([network]), viaBackend }); } } return chunks.map(c => c.keys); @@ -629,9 +634,12 @@ const alchemyPricesByAddress = createBatchedLoader({ return { chainId: Number(chainId), network: ALCHEMY_NETWORKS[Number(chainId)], address }; }); - const sendable = tokens - .filter(({ address }) => ADDRESS_PATTERN.test(address)) - .map(({ chainId, address }) => ({ chainId, address })); + const viaBackend = tokens.every(({ chainId }) => BACKEND_PRICE_CHAIN_IDS.has(chainId)); + const sendable = viaBackend + ? tokens + .filter(({ address }) => ADDRESS_PATTERN.test(address)) + .map(({ chainId, address }) => ({ chainId, address })) + : []; const fromBackend = sendable.length ? await fetchBackendPrices({ tokens: sendable }) : undefined; From 0de39473188825d3d20b934710effde662e5c80c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:07:09 +0000 Subject: [PATCH 4/4] revert(polling): restore the 5s balance, 5s native price and 3s vault polls The slower polls were there to save the Prices API quota. Price lookups are now cached for a minute, in the app and by the backend's shared cache, so polling more often no longer costs token_price requests, and the slower intervals only made balance updates visibly lag. Balances poll every 5s again (staleTime 5s), native-token prices every 5s, and vault balances every 3s, as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UMuZxhszcH7Kc5s7dc2NNq --- hooks/useBalances.ts | 10 +++++----- hooks/useNativePriceUsd.ts | 8 ++++---- hooks/useVault.ts | 8 +------- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/hooks/useBalances.ts b/hooks/useBalances.ts index c060822f9..31a29a864 100644 --- a/hooks/useBalances.ts +++ b/hooks/useBalances.ts @@ -733,18 +733,18 @@ export const tokenBalancesQueryOptions = (safeAddress: string | undefined) => ({ /** * SSE handles real-time updates (useActivitySSE invalidates ['tokenBalances'] - * on every balance event); polling is only the fallback for missed events or - * SSE failure. Each refresh is ~11 Alchemy RPC calls across five chains plus - * the price lookups, and at the old 5s one open app alone spent over a third - * of the app-wide 10,000/hour Prices API quota. + * on every balance event); polling is the fallback for missed events or SSE + * failure. The price lookups inside a refresh are cached for a minute, so this + * interval doesn't drive Prices API usage. */ -const BALANCES_POLL_INTERVAL_MS = 30_000; +const BALANCES_POLL_INTERVAL_MS = 5_000; export const useBalances = (): BalanceData => { const { user } = useUser(); const { data, isLoading, isRefetching, error, refetch } = useQuery({ ...tokenBalancesQueryOptions(user?.safeAddress), + staleTime: 5_000, retry: 3, // retry up to 3 times on failure retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff refetchOnWindowFocus: true, // refetch when user returns to tab diff --git a/hooks/useNativePriceUsd.ts b/hooks/useNativePriceUsd.ts index 6e4ce39bd..48379b8e1 100644 --- a/hooks/useNativePriceUsd.ts +++ b/hooks/useNativePriceUsd.ts @@ -4,11 +4,11 @@ import { NATIVE_COINGECKO_TOKENS, NATIVE_TOKENS } from '@/constants/tokens'; import { fetchCoinSimplePrice, fetchTokenPriceUsd } from '@/lib/api'; /** - * How often native-token prices refresh. Alchemy updates its prices about once - * a minute, so polling faster (this was 5s) only spent Prices API quota on the - * same number. + * How often native-token prices refresh. Polling this often costs no Prices API + * quota of its own: the price lookups are cached for a minute, in the app and + * by the backend's shared cache. */ -export const NATIVE_PRICE_REFRESH_MS = 60_000; +export const NATIVE_PRICE_REFRESH_MS = 5_000; /** * Native-token USD price fetcher for a chain (Alchemy first, CoinGecko fallback). diff --git a/hooks/useVault.ts b/hooks/useVault.ts index 880baa2c6..c39f9c3a4 100644 --- a/hooks/useVault.ts +++ b/hooks/useVault.ts @@ -13,13 +13,7 @@ import { config } from '@/lib/wagmi'; // Cache configuration for vault queries const VAULT_STALE_TIME = secondsToMilliseconds(3); // Consider data fresh for 3 seconds const VAULT_GC_TIME = secondsToMilliseconds(300); // Keep in cache for 5 minutes -/** - * Fallback poll. Real-time updates come from SSE, which invalidates ['vault'] on - * every balance event (useActivitySSE), and withdrawals invalidate it too. The - * old 3s poll read the two Ethereum vaults over Alchemy ~40 times a minute per - * open app, for a balance that only moves on a transaction. - */ -const VAULT_REFETCH_INTERVAL = secondsToMilliseconds(30); +const VAULT_REFETCH_INTERVAL = secondsToMilliseconds(3); // Poll every 3 seconds for near-realtime updates export const VAULT = 'vault';