diff --git a/constants/alchemy.ts b/constants/alchemy.ts index 9383b7edf..b0e367b6c 100644 --- a/constants/alchemy.ts +++ b/constants/alchemy.ts @@ -39,9 +39,34 @@ 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`; -/** 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..31a29a864 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 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 = 5_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: + ...tokenBalancesQueryOptions(user?.safeAddress), staleTime: 5_000, - gcTime: 5 * 60 * 1000, // 5 minutes - data stays in cache for 5 minutes when unused 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..48379b8e1 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. 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 = 5_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..c39f9c3a4 100644 --- a/hooks/useVault.ts +++ b/hooks/useVault.ts @@ -32,7 +32,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..f086e2f89 100644 --- a/lib/__tests__/alchemyTokenPrices.test.ts +++ b/lib/__tests__/alchemyTokenPrices.test.ts @@ -6,11 +6,18 @@ 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 }) }, })); +// 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(), @@ -31,8 +38,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 +58,145 @@ 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: {} }, + }); + +/** + * 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(); +}); + +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 +328,185 @@ 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); + }); +}); + +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('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: {} }); + + 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/__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..528bb9c3f 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -7,9 +7,13 @@ import { fuse } from 'viem/chains'; import { ALCHEMY_NETWORKS, ALCHEMY_PRICE_BATCH_SIZE, + 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'; import { fetchTokenTransferWithFallback } from '@/lib/data-source'; import { fetchWithTimeout } from '@/lib/fetchWithTimeout'; import { toTransfiError } from '@/lib/transfiErrors'; @@ -444,74 +448,281 @@ export const fetchTokenTransfer = async ({ }); }; -export const fetchTokenPriceUsd = async (token: string) => { +/** + * 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, +}; + +/** 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=${token}`, + `${ALCHEMY_PRICES_URL}/by-symbol?${symbols.map(s => `symbols=${encodeURIComponent(s)}`).join('&')}`, + { timeout: ALCHEMY_REQUEST_TIMEOUT_MS }, ); - return response?.data?.data[0]?.prices[0]?.value; + 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, + 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 => { + // 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 fetchAlchemyPricesBySymbol(symbols); + }, +}); + +/** 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. 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; viaBackend: boolean }[] = []; + for (const key of keys) { + 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), + ); + if (chunk) { + chunk.keys.push(key); + chunk.networks.add(network); + } else { + chunks.push({ keys: [key], networks: new Set([network]), viaBackend }); + } + } + 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 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; + // 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 })) }, + { 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 price and lifts any pause on the backend or on Alchemy. */ +export const clearAlchemyPriceCache = () => { + alchemyPricesBySymbol.clear(); + alchemyPricesByAddress.clear(); + backendPricesPausedUntil = 0; +}; + +/** + * 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 }); };