Skip to content

Commit e1ff173

Browse files
authored
Fetch token rates in batches of 100 (#3650)
In cases where users have an enormous amount of tokens, we don't want to slow down our Price API with large requests. To combat this, when fetching token rates, send requests in batches of 100.
1 parent 42d0b32 commit e1ff173

File tree

5 files changed

+365
-15
lines changed

5 files changed

+365
-15
lines changed

packages/assets-controllers/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
- **BREAKING:** Change signature of `TokenRatesController.fetchAndMapExchangeRates` ([#3600](https://github.com/MetaMask/core/pull/3600))
1919
- This method now takes an object with shape `{ tokenContractAddresses: Hex[]; chainId: Hex; nativeCurrency: string; }` rather than positional arguments
2020
- Update TokenListController to fetch prefiltered set of tokens from the API, reducing response data and removing the need for filtering logic ([#2054](https://github.com/MetaMask/core/pull/2054))
21+
- Update TokenRatesController to request token rates from the Price API in batches of 100 ([#3650](https://github.com/MetaMask/core/pull/3650))
2122

2223
### Removed
2324
- **BREAKING:** Remove `fetchExchangeRate` method from TokenRatesController ([#3600](https://github.com/MetaMask/core/pull/3600))

packages/assets-controllers/src/TokenRatesController.test.ts

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
import { NetworksTicker, toHex } from '@metamask/controller-utils';
1+
import {
2+
NetworksTicker,
3+
toChecksumHexAddress,
4+
toHex,
5+
} from '@metamask/controller-utils';
26
import type { NetworkState } from '@metamask/network-controller';
37
import type { PreferencesState } from '@metamask/preferences-controller';
48
import type { Hex } from '@metamask/utils';
9+
import { add0x } from '@metamask/utils';
510
import nock from 'nock';
611
import { useFakeTimers } from 'sinon';
712

@@ -13,7 +18,7 @@ import type {
1318
} from './token-prices-service/abstract-token-prices-service';
1419
import type { TokenBalancesState } from './TokenBalancesController';
1520
import { TokenRatesController } from './TokenRatesController';
16-
import type { TokenRatesConfig } from './TokenRatesController';
21+
import type { TokenRatesConfig, Token } from './TokenRatesController';
1722
import type { TokensState } from './TokensController';
1823

1924
const defaultSelectedAddress = '0x0000000000000000000000000000000000000001';
@@ -1682,6 +1687,58 @@ describe('TokenRatesController', () => {
16821687
);
16831688
});
16841689

1690+
it('fetches rates for all tokens in batches of 100', async () => {
1691+
const chainId = toHex(1);
1692+
const ticker = 'ETH';
1693+
const tokenAddresses = [...new Array(200).keys()]
1694+
.map(buildAddress)
1695+
.sort();
1696+
const tokenPricesService = buildMockTokenPricesService({
1697+
fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken,
1698+
});
1699+
const fetchTokenPricesSpy = jest.spyOn(
1700+
tokenPricesService,
1701+
'fetchTokenPrices',
1702+
);
1703+
const tokens = tokenAddresses.map((tokenAddress) => {
1704+
return buildToken({ address: tokenAddress });
1705+
});
1706+
await withController(
1707+
{
1708+
options: {
1709+
ticker,
1710+
tokenPricesService,
1711+
},
1712+
},
1713+
async ({ controller, controllerEvents }) => {
1714+
await callUpdateExchangeRatesMethod({
1715+
allTokens: {
1716+
[chainId]: {
1717+
[controller.config.selectedAddress]: tokens,
1718+
},
1719+
},
1720+
chainId,
1721+
controller,
1722+
controllerEvents,
1723+
method,
1724+
nativeCurrency: ticker,
1725+
});
1726+
1727+
expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(2);
1728+
expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(1, {
1729+
chainId,
1730+
tokenContractAddresses: tokenAddresses.slice(0, 100),
1731+
currency: ticker,
1732+
});
1733+
expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(2, {
1734+
chainId,
1735+
tokenContractAddresses: tokenAddresses.slice(100),
1736+
currency: ticker,
1737+
});
1738+
},
1739+
);
1740+
});
1741+
16851742
it('updates all rates', async () => {
16861743
const tokenAddresses = [
16871744
'0x0000000000000000000000000000000000000001',
@@ -1900,6 +1957,68 @@ describe('TokenRatesController', () => {
19001957
);
19011958
});
19021959

1960+
it('fetches rates for all tokens in batches of 100 when native currency is not supported by the Price API', async () => {
1961+
const chainId = toHex(1);
1962+
const ticker = 'UNSUPPORTED';
1963+
const tokenAddresses = [...new Array(200).keys()]
1964+
.map(buildAddress)
1965+
.sort();
1966+
const tokenPricesService = buildMockTokenPricesService({
1967+
fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken,
1968+
validateCurrencySupported: (currency: unknown): currency is string => {
1969+
return currency !== ticker;
1970+
},
1971+
});
1972+
const fetchTokenPricesSpy = jest.spyOn(
1973+
tokenPricesService,
1974+
'fetchTokenPrices',
1975+
);
1976+
const tokens = tokenAddresses.map((tokenAddress) => {
1977+
return buildToken({ address: tokenAddress });
1978+
});
1979+
nock('https://min-api.cryptocompare.com')
1980+
.get('/data/price')
1981+
.query({
1982+
fsym: 'ETH',
1983+
tsyms: ticker,
1984+
})
1985+
.reply(200, { [ticker]: 0.5 });
1986+
await withController(
1987+
{
1988+
options: {
1989+
ticker,
1990+
tokenPricesService,
1991+
},
1992+
},
1993+
async ({ controller, controllerEvents }) => {
1994+
await callUpdateExchangeRatesMethod({
1995+
allTokens: {
1996+
[chainId]: {
1997+
[controller.config.selectedAddress]: tokens,
1998+
},
1999+
},
2000+
chainId,
2001+
controller,
2002+
controllerEvents,
2003+
method,
2004+
nativeCurrency: ticker,
2005+
});
2006+
2007+
expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(2);
2008+
expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(1, {
2009+
chainId,
2010+
tokenContractAddresses: tokenAddresses.slice(0, 100),
2011+
currency: 'ETH',
2012+
});
2013+
expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(2, {
2014+
chainId,
2015+
tokenContractAddresses: tokenAddresses.slice(100),
2016+
currency: 'ETH',
2017+
});
2018+
},
2019+
);
2020+
});
2021+
19032022
it('sets rates to undefined when chain is not supported by the Price API', async () => {
19042023
const tokenAddresses = [
19052024
'0x0000000000000000000000000000000000000001',
@@ -2284,3 +2403,32 @@ async function fetchTokenPricesWithIncreasingPriceForEachToken<
22842403
};
22852404
}, {}) as TokenPricesByTokenContractAddress<TokenAddress, Currency>;
22862405
}
2406+
2407+
/**
2408+
* Constructs a checksum Ethereum address.
2409+
*
2410+
* @param number - The address as a decimal number.
2411+
* @returns The address as an 0x-prefixed ERC-55 mixed-case checksum address in
2412+
* hexadecimal format.
2413+
*/
2414+
function buildAddress(number: number) {
2415+
return toChecksumHexAddress(add0x(number.toString(16).padStart(40, '0')));
2416+
}
2417+
2418+
/**
2419+
* Constructs an object that satisfies the Token interface, filling in missing
2420+
* properties with defaults. This makes it possible to only specify properties
2421+
* that the test cares about.
2422+
*
2423+
* @param overrides - The properties that should be assigned to the new token.
2424+
* @returns The constructed token.
2425+
*/
2426+
function buildToken(overrides: Partial<Token> = {}) {
2427+
return {
2428+
address: buildAddress(1),
2429+
decimals: 0,
2430+
symbol: '',
2431+
aggregators: [],
2432+
...overrides,
2433+
};
2434+
}

packages/assets-controllers/src/TokenRatesController.ts

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { PreferencesState } from '@metamask/preferences-controller';
1515
import type { Hex } from '@metamask/utils';
1616
import { isDeepStrictEqual } from 'util';
1717

18+
import { reduceInBatchesSerially } from './assetsUtil';
1819
import { fetchExchangeRate as fetchNativeCurrencyExchangeRate } from './crypto-compare';
1920
import type { AbstractTokenPricesService } from './token-prices-service/abstract-token-prices-service';
2021
import type { TokensState } from './TokensController';
@@ -95,6 +96,12 @@ export interface TokenRatesState extends BaseState {
9596
>;
9697
}
9798

99+
/**
100+
* The maximum number of token addresses that should be sent to the Price API in
101+
* a single request.
102+
*/
103+
const TOKEN_PRICES_BATCH_SIZE = 100;
104+
98105
/**
99106
* Uses the CryptoCompare API to fetch the exchange rate between one currency
100107
* and another, i.e., the multiplier to apply the amount of one currency in
@@ -505,12 +512,27 @@ export class TokenRatesController extends StaticIntervalPollingControllerV1<
505512
chainId: Hex;
506513
nativeCurrency: string;
507514
}): Promise<ContractExchangeRates> {
508-
const tokenPricesByTokenContractAddress =
509-
await this.#tokenPricesService.fetchTokenPrices({
510-
tokenContractAddresses,
511-
chainId,
512-
currency: nativeCurrency,
513-
});
515+
const tokenPricesByTokenContractAddress = await reduceInBatchesSerially<
516+
Hex,
517+
Awaited<ReturnType<AbstractTokenPricesService['fetchTokenPrices']>>
518+
>({
519+
values: tokenContractAddresses,
520+
batchSize: TOKEN_PRICES_BATCH_SIZE,
521+
eachBatch: async (allTokenPricesByTokenContractAddress, batch) => {
522+
const tokenPricesByTokenContractAddressForBatch =
523+
await this.#tokenPricesService.fetchTokenPrices({
524+
tokenContractAddresses: batch,
525+
chainId,
526+
currency: nativeCurrency,
527+
});
528+
529+
return {
530+
...allTokenPricesByTokenContractAddress,
531+
...tokenPricesByTokenContractAddressForBatch,
532+
};
533+
},
534+
initialResult: {},
535+
});
514536

515537
return Object.entries(tokenPricesByTokenContractAddress).reduce(
516538
(obj, [tokenContractAddress, tokenPrice]) => {
@@ -543,13 +565,13 @@ export class TokenRatesController extends StaticIntervalPollingControllerV1<
543565
nativeCurrency: string;
544566
}): Promise<ContractExchangeRates> {
545567
const [
546-
tokenPricesByTokenContractAddress,
568+
contractExchangeRates,
547569
fallbackCurrencyToNativeCurrencyConversionRate,
548570
] = await Promise.all([
549-
this.#tokenPricesService.fetchTokenPrices({
571+
this.#fetchAndMapExchangeRatesForSupportedNativeCurrency({
550572
tokenContractAddresses,
551-
currency: FALL_BACK_VS_CURRENCY,
552573
chainId: this.config.chainId,
574+
nativeCurrency: FALL_BACK_VS_CURRENCY,
553575
}),
554576
getCurrencyConversionRate({
555577
from: FALL_BACK_VS_CURRENCY,
@@ -561,12 +583,13 @@ export class TokenRatesController extends StaticIntervalPollingControllerV1<
561583
return {};
562584
}
563585

564-
return Object.entries(tokenPricesByTokenContractAddress).reduce(
565-
(obj, [tokenContractAddress, tokenPrice]) => {
586+
return Object.entries(contractExchangeRates).reduce(
587+
(obj, [tokenContractAddress, tokenValue]) => {
566588
return {
567589
...obj,
568-
[tokenContractAddress]:
569-
tokenPrice.value * fallbackCurrencyToNativeCurrencyConversionRate,
590+
[tokenContractAddress]: tokenValue
591+
? tokenValue * fallbackCurrencyToNativeCurrencyConversionRate
592+
: undefined,
570593
};
571594
},
572595
{},

0 commit comments

Comments
 (0)