From 824ecacc98514a3ade2dcd22f4bc5b8e2a2d01cd Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:42:06 +0000 Subject: [PATCH] fix(prices): Batch Alchemy token price requests to avoid 429 --- hooks/useBalances.ts | 30 ++++------ lib/__tests__/alchemyTokenPrices.test.ts | 70 +++++++++++++++++++++++- lib/api.ts | 37 +++++++++++++ 3 files changed, 114 insertions(+), 23 deletions(-) diff --git a/hooks/useBalances.ts b/hooks/useBalances.ts index bc300726..6e601479 100644 --- a/hooks/useBalances.ts +++ b/hooks/useBalances.ts @@ -8,7 +8,7 @@ import { fetchCoinSimplePrice, fetchTokenList, fetchTokenPricesByAddress, - fetchTokenPriceUsd, + fetchTokenPricesUsdBatch, } from '@/lib/api'; import { ADDRESSES } from '@/lib/config'; import { fetchTokenBalancesWithFallback } from '@/lib/data-source'; @@ -580,25 +580,15 @@ 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); - } + // Single batched request for all symbols instead of one request per symbol, + // to avoid exceeding Alchemy's rate limit (10 000 token_price req/hr). + const symbolToPrice = await fetchTokenPricesUsdBatch(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 diff --git a/lib/__tests__/alchemyTokenPrices.test.ts b/lib/__tests__/alchemyTokenPrices.test.ts index 6e7b2be7..4b8398a3 100644 --- a/lib/__tests__/alchemyTokenPrices.test.ts +++ b/lib/__tests__/alchemyTokenPrices.test.ts @@ -31,8 +31,11 @@ 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 axiosDefault = (require('axios') as { default: { post: jest.Mock; get: jest.Mock } }).default; +const post = axiosDefault.post; +const get = axiosDefault.get; +const { fetchTokenPricesByAddress, fetchTokenPricesUsdBatch } = + require('@/lib/api') as typeof import('@/lib/api'); /* eslint-enable @typescript-eslint/no-require-imports */ const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; @@ -45,7 +48,10 @@ const priced = (network: string, address: string, value: string) => ({ prices: [{ currency: 'usd', value, lastUpdatedAt: '2026-09-09T00:00:00Z' }], }); -beforeEach(() => post.mockReset()); +beforeEach(() => { + post.mockReset(); + get.mockReset(); +}); describe('fetchTokenPricesByAddress', () => { it('keys prices by chain id and lowercased address', async () => { @@ -178,3 +184,61 @@ describe('fetchTokenPricesByAddress', () => { expect(post).not.toHaveBeenCalled(); }); }); + +describe('fetchTokenPricesUsdBatch', () => { + const symbolEntry = (symbol: string, value: string) => ({ + symbol, + prices: [{ currency: 'usd', value, lastUpdatedAt: '2026-09-09T00:00:00Z' }], + }); + + it('returns an empty record without calling Alchemy when given no symbols', async () => { + await expect(fetchTokenPricesUsdBatch([])).resolves.toEqual({}); + expect(get).not.toHaveBeenCalled(); + }); + + it('issues a single GET request with all symbols as repeated query params', async () => { + get.mockResolvedValue({ data: { data: [] } }); + + await fetchTokenPricesUsdBatch(['ETH', 'BTC', 'USDC']); + + expect(get).toHaveBeenCalledTimes(1); + const url: string = get.mock.calls[0][0]; + expect(url).toContain('symbols=ETH'); + expect(url).toContain('symbols=BTC'); + expect(url).toContain('symbols=USDC'); + }); + + it('maps each symbol to its USD price as a number', async () => { + get.mockResolvedValue({ + data: { + data: [symbolEntry('ETH', '3500.12'), symbolEntry('BTC', '65000.00')], + }, + }); + + const prices = await fetchTokenPricesUsdBatch(['ETH', 'BTC']); + + expect(prices).toEqual({ ETH: 3500.12, BTC: 65000.0 }); + }); + + it('omits symbols Alchemy cannot price (empty prices array)', async () => { + get.mockResolvedValue({ + data: { + data: [ + { symbol: 'SCAM', prices: [] }, + symbolEntry('USDC', '1.0001'), + ], + }, + }); + + const prices = await fetchTokenPricesUsdBatch(['SCAM', 'USDC']); + + expect(prices).not.toHaveProperty('SCAM'); + expect(prices).toHaveProperty('USDC', 1.0001); + }); + + it('returns an empty record and does not throw when the request fails', async () => { + get.mockRejectedValue(new Error('429 Too Many Requests')); + + await expect(fetchTokenPricesUsdBatch(['ETH'])).resolves.toEqual({}); + }); +}); diff --git a/lib/api.ts b/lib/api.ts index 32ac3257..8e5de5ca 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -451,6 +451,43 @@ export const fetchTokenPriceUsd = async (token: string) => { return response?.data?.data[0]?.prices[0]?.value; }; +/** + * USD prices for a batch of token symbols from Alchemy's Prices API. + * + * Issues a **single** GET request with all symbols in one query string instead + * of one request per symbol, avoiding Alchemy's 10,000 req/hr rate limit when + * many tokens need pricing simultaneously. + * + * Returns a Record mapping each symbol (uppercased as returned by Alchemy) to + * its USD price as a number. Symbols Alchemy cannot price are omitted. + * + * Never throws: a failed request resolves to an empty record so callers + * degrade gracefully. + */ +export const fetchTokenPricesUsdBatch = async ( + symbols: string[], +): Promise> => { + if (symbols.length === 0) return {}; + try { + const params = new URLSearchParams(symbols.map(s => ['symbols', s])); + const response = await externalAxios.get( + `${ALCHEMY_PRICES_URL}/by-symbol?${params.toString()}`, + ); + const result: Record = {}; + for (const entry of response?.data?.data ?? []) { + const raw = entry?.prices?.[0]?.value; + if (raw == null) continue; + const price = parseFloat(raw); + if (Number.isFinite(price) && price > 0) { + result[entry.symbol] = price; + } + } + return result; + } catch { + return {}; + } +}; + /** * USD prices for ERC-20s from Alchemy's Prices API, keyed by * `${chainId}:${lowercased address}`.