Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 10 additions & 20 deletions hooks/useBalances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
fetchCoinSimplePrice,
fetchTokenList,
fetchTokenPricesByAddress,
fetchTokenPriceUsd,
fetchTokenPricesUsdBatch,
} from '@/lib/api';
import { ADDRESSES } from '@/lib/config';
import { fetchTokenBalancesWithFallback } from '@/lib/data-source';
Expand Down Expand Up @@ -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<string, number> = {};
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
Expand Down
70 changes: 67 additions & 3 deletions lib/__tests__/alchemyTokenPrices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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({});
});
});
37 changes: 37 additions & 0 deletions lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, number>> => {
if (symbols.length === 0) return {};
try {
const params = new URLSearchParams(symbols.map(s => ['symbols', s]));
const response = await externalAxios.get<TokenPriceUsd>(
`${ALCHEMY_PRICES_URL}/by-symbol?${params.toString()}`,
);
const result: Record<string, number> = {};
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}`.
Expand Down
Loading