Skip to content

Commit f57996a

Browse files
committed
fix(assets-controller): hardened error handling
1 parent 6a7b2d8 commit f57996a

File tree

9 files changed

+226
-43
lines changed

9 files changed

+226
-43
lines changed

packages/assets-controller/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- `TokenDataSource` splits v3 asset metadata fetches into batches of at most 120 asset IDs per request, matching the Tokens API limit, and runs chunk requests in parallel with bounded concurrency via `p-limit` ([#8294](https://github.com/MetaMask/core/pull/8294))
13+
- `PriceDataSource` splits v3 spot-price fetches into batches of at most 120 asset IDs per request, matching the same scale as token metadata requests, with bounded concurrency via `p-limit` ([#8294](https://github.com/MetaMask/core/pull/8294))
14+
1015
## [3.1.0]
1116

1217
### Changed

packages/assets-controller/src/AssetsController.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,7 +622,14 @@ export class AssetsController extends BaseController<
622622
chains: ChainId[],
623623
previousChains: ChainId[],
624624
): void => {
625-
this.#handleActiveChainsUpdate(dataSourceName, chains, previousChains);
625+
try {
626+
this.#handleActiveChainsUpdate(dataSourceName, chains, previousChains);
627+
} catch (error) {
628+
log('Failed to handle active chains update', {
629+
dataSourceName,
630+
error,
631+
});
632+
}
626633
};
627634

628635
this.#backendWebsocketDataSource = new BackendWebsocketDataSource({

packages/assets-controller/src/data-sources/PriceDataSource.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,75 @@ describe('PriceDataSource', () => {
410410
controller.destroy();
411411
});
412412

413+
it('fetch batches spot price requests in chunks of 120 asset IDs', async () => {
414+
const assetIds = Array.from({ length: 121 }, (_, i) => {
415+
const hex = i.toString(16).padStart(40, '0');
416+
return `eip155:1/erc20:0x${hex}` as Caip19AssetId;
417+
});
418+
const balanceState: Record<string, Record<string, unknown>> = {
419+
'mock-account-id': Object.fromEntries(
420+
assetIds.map((id) => [id, { amount: '1' }]),
421+
),
422+
};
423+
const priceResponse = Object.fromEntries(
424+
assetIds.map((id) => [id, createMockPriceData(1)]),
425+
);
426+
427+
const { controller, apiClient, getAssetsState } = setupController({
428+
balanceState,
429+
priceResponse,
430+
});
431+
432+
await controller.fetch(createDataRequest({ chainIds: [] }), getAssetsState);
433+
434+
expect(apiClient.prices.fetchV3SpotPrices).toHaveBeenCalledTimes(2);
435+
const chunkSizes = apiClient.prices.fetchV3SpotPrices.mock.calls
436+
.map((call) => (call[0] as string[]).length)
437+
.sort((a, b) => b - a);
438+
expect(chunkSizes).toStrictEqual([120, 1]);
439+
440+
controller.destroy();
441+
});
442+
443+
it('fetch batches spot price requests per chunk for non-USD currency', async () => {
444+
const assetIds = Array.from({ length: 121 }, (_, i) => {
445+
const hex = i.toString(16).padStart(40, '0');
446+
return `eip155:1/erc20:0x${hex}` as Caip19AssetId;
447+
});
448+
const balanceState: Record<string, Record<string, unknown>> = {
449+
'mock-account-id': Object.fromEntries(
450+
assetIds.map((id) => [id, { amount: '1' }]),
451+
),
452+
};
453+
const priceResponse = Object.fromEntries(
454+
assetIds.map((id) => [id, createMockPriceData(1)]),
455+
);
456+
457+
const { controller, apiClient, getAssetsState } = setupController({
458+
getSelectedCurrency: () => 'eur',
459+
balanceState,
460+
priceResponse,
461+
});
462+
463+
await controller.fetch(createDataRequest({ chainIds: [] }), getAssetsState);
464+
465+
expect(apiClient.prices.fetchV3SpotPrices).toHaveBeenCalledTimes(4);
466+
const eurChunks = apiClient.prices.fetchV3SpotPrices.mock.calls.filter(
467+
(call) => (call[1] as { currency?: string }).currency === 'eur',
468+
);
469+
const usdChunks = apiClient.prices.fetchV3SpotPrices.mock.calls.filter(
470+
(call) => (call[1] as { currency?: string }).currency === 'usd',
471+
);
472+
expect(eurChunks).toHaveLength(2);
473+
expect(usdChunks).toHaveLength(2);
474+
const eurSizes = eurChunks
475+
.map((call) => (call[0] as string[]).length)
476+
.sort((a, b) => b - a);
477+
expect(eurSizes).toStrictEqual([120, 1]);
478+
479+
controller.destroy();
480+
});
481+
413482
it('fetch handles getState error gracefully', async () => {
414483
const { controller } = setupController();
415484

packages/assets-controller/src/data-sources/PriceDataSource.ts

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
} from '@metamask/core-backend';
55
import { ApiPlatformClient } from '@metamask/core-backend';
66
import { parseCaipAssetType } from '@metamask/utils';
7+
import pLimit from 'p-limit';
78

89
import type { SubscriptionRequest } from './AbstractDataSource';
910
import { projectLogger, createModuleLogger } from '../logger';
@@ -24,6 +25,12 @@ import type {
2425
const CONTROLLER_NAME = 'PriceDataSource';
2526
const DEFAULT_POLL_INTERVAL = 60_000; // 1 minute for price updates
2627

28+
/** Price API v3 spot-prices accepts at most this many `assetIds` per request (same cap as tokens `/v3/assets`). */
29+
const V3_SPOT_PRICES_MAX_IDS_PER_REQUEST = 120;
30+
31+
/** Max concurrent spot-price chunk requests (aligned with TokenDataSource metadata fetches). */
32+
const V3_SPOT_PRICES_FETCH_CONCURRENCY = 3;
33+
2734
const log = createModuleLogger(projectLogger, CONTROLLER_NAME);
2835

2936
// ============================================================================
@@ -219,30 +226,52 @@ export class PriceDataSource {
219226
async #fetchSpotPrices(
220227
assetIds: string[],
221228
): Promise<Record<Caip19AssetId, FungibleAssetPrice>> {
229+
if (assetIds.length === 0) {
230+
return {};
231+
}
232+
222233
const selectedCurrency = this.#getSelectedCurrency();
234+
const chunks: string[][] = [];
235+
for (
236+
let i = 0;
237+
i < assetIds.length;
238+
i += V3_SPOT_PRICES_MAX_IDS_PER_REQUEST
239+
) {
240+
chunks.push(
241+
assetIds.slice(i, i + V3_SPOT_PRICES_MAX_IDS_PER_REQUEST),
242+
);
243+
}
244+
245+
const limit = pLimit(V3_SPOT_PRICES_FETCH_CONCURRENCY);
246+
const queryOpts = { includeMarketData: true as const };
247+
248+
const selectedChunkResults = await Promise.all(
249+
chunks.map((chunk) =>
250+
limit(() =>
251+
this.#apiClient.prices.fetchV3SpotPrices(chunk, {
252+
currency: selectedCurrency,
253+
...queryOpts,
254+
}),
255+
),
256+
),
257+
);
258+
const selectedCurrencyPrices = Object.assign({}, ...selectedChunkResults);
223259

224-
let selectedCurrencyPrices: V3SpotPricesResponse;
225260
let usdPrices: V3SpotPricesResponse;
226261
if (selectedCurrency === 'usd') {
227-
selectedCurrencyPrices = await this.#apiClient.prices.fetchV3SpotPrices(
228-
assetIds,
229-
{
230-
currency: selectedCurrency,
231-
includeMarketData: true,
232-
},
233-
);
234262
usdPrices = selectedCurrencyPrices;
235263
} else {
236-
[selectedCurrencyPrices, usdPrices] = await Promise.all([
237-
this.#apiClient.prices.fetchV3SpotPrices(assetIds, {
238-
currency: selectedCurrency,
239-
includeMarketData: true,
240-
}),
241-
this.#apiClient.prices.fetchV3SpotPrices(assetIds, {
242-
currency: 'usd',
243-
includeMarketData: true,
244-
}),
245-
]);
264+
const usdChunkResults = await Promise.all(
265+
chunks.map((chunk) =>
266+
limit(() =>
267+
this.#apiClient.prices.fetchV3SpotPrices(chunk, {
268+
currency: 'usd',
269+
...queryOpts,
270+
}),
271+
),
272+
),
273+
);
274+
usdPrices = Object.assign({}, ...usdChunkResults);
246275
}
247276

248277
const prices: Record<Caip19AssetId, FungibleAssetPrice> = {};

packages/assets-controller/src/data-sources/RpcDataSource.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -280,9 +280,14 @@ export class RpcDataSource extends AbstractDataSource<
280280
balanceFetcherMessenger,
281281
{ pollingInterval: balanceInterval },
282282
);
283-
this.#balanceFetcher.setOnBalanceUpdate(
284-
this.#handleBalanceUpdate.bind(this),
285-
);
283+
// Polling controller awaits this callback; rejections must not become unhandled.
284+
this.#balanceFetcher.setOnBalanceUpdate(async (result) => {
285+
try {
286+
await this.#handleBalanceUpdate(result);
287+
} catch (error) {
288+
log('Balance update handler failed', { error });
289+
}
290+
});
286291

287292
// Initialize TokenDetector with polling interval
288293
this.#tokenDetector = new TokenDetector(
@@ -294,9 +299,14 @@ export class RpcDataSource extends AbstractDataSource<
294299
useExternalService: this.#useExternalService,
295300
},
296301
);
297-
this.#tokenDetector.setOnDetectionUpdate(
298-
this.#handleDetectionUpdate.bind(this),
299-
);
302+
// Sync throw in the detector would reject the poll tick if uncaught.
303+
this.#tokenDetector.setOnDetectionUpdate((result) => {
304+
try {
305+
this.#handleDetectionUpdate(result);
306+
} catch (error) {
307+
log('Detection update handler failed', { error });
308+
}
309+
});
300310

301311
this.#subscribeToNetworkController();
302312
this.#subscribeToTransactionEvents();

packages/assets-controller/src/data-sources/SnapDataSource.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,10 +268,10 @@ export class SnapDataSource extends AbstractDataSource<
268268
// Transform the snap keyring payload to DataResponse format
269269
let assetsBalance: NonNullable<DataResponse['assetsBalance']> | undefined;
270270

271-
for (const [accountId, assets] of Object.entries(payload.balances)) {
271+
for (const [accountId, assets] of Object.entries(payload?.balances ?? {})) {
272272
let accountAssets: Record<Caip19AssetId, AssetBalance> | undefined;
273273

274-
for (const [assetId, balance] of Object.entries(assets)) {
274+
for (const [assetId, balance] of Object.entries(assets ?? {})) {
275275
let chainId: ChainId;
276276
try {
277277
chainId = extractChainFromAssetId(assetId);

packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,14 @@ export class StakedBalanceDataSource extends AbstractDataSource<
193193
});
194194

195195
// Wire the callback so polling results flow back to subscriptions
196-
this.#stakedBalanceFetcher.setOnStakedBalanceUpdate(
197-
this.#handleStakedBalanceUpdate.bind(this),
198-
);
196+
// Polling controller invokes this synchronously; keep failures inside the poll tick.
197+
this.#stakedBalanceFetcher.setOnStakedBalanceUpdate((result) => {
198+
try {
199+
this.#handleStakedBalanceUpdate(result);
200+
} catch (error) {
201+
log('Staked balance update handler failed', { error });
202+
}
203+
});
199204

200205
this.#messenger.subscribe(
201206
'TransactionController:transactionConfirmed',

packages/assets-controller/src/data-sources/TokenDataSource.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,39 @@ describe('TokenDataSource', () => {
429429
expect(next).toHaveBeenCalledWith(context);
430430
});
431431

432+
it('middleware chunks fetchV3Assets when more than 120 asset IDs are requested', async () => {
433+
const assetIds = Array.from({ length: 121 }, (_, i) => {
434+
const hex = (i + 1).toString(16).padStart(40, '0');
435+
return `eip155:1/erc20:0x${hex}` as Caip19AssetId;
436+
});
437+
438+
const { controller, apiClient } = setupController({
439+
supportedNetworks: ['eip155:1'],
440+
});
441+
442+
apiClient.tokens.fetchV3Assets.mockImplementation((ids: string[]) =>
443+
Promise.resolve(ids.map((id) => createMockAssetResponse(id))),
444+
);
445+
446+
const next = jest.fn().mockResolvedValue(undefined);
447+
const context = createMiddlewareContext({
448+
response: {
449+
detectedAssets: {
450+
'mock-account-id': assetIds,
451+
},
452+
},
453+
});
454+
455+
await controller.assetsMiddleware(context, next);
456+
457+
expect(apiClient.tokens.fetchV3Assets).toHaveBeenCalledTimes(2);
458+
expect(apiClient.tokens.fetchV3Assets.mock.calls[0][0]).toHaveLength(120);
459+
expect(apiClient.tokens.fetchV3Assets.mock.calls[1][0]).toHaveLength(1);
460+
expect(context.response.assetsInfo?.[assetIds[0]]?.symbol).toBe('TEST');
461+
expect(context.response.assetsInfo?.[assetIds[120]]?.symbol).toBe('TEST');
462+
expect(next).toHaveBeenCalledWith(context);
463+
});
464+
432465
it('middleware transforms native asset type correctly', async () => {
433466
const { controller } = setupController({
434467
supportedNetworks: ['eip155:1'],

packages/assets-controller/src/data-sources/TokenDataSource.ts

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { V3AssetResponse } from '@metamask/core-backend';
22
import { ApiPlatformClient } from '@metamask/core-backend';
33
import { parseCaipAssetType } from '@metamask/utils';
44
import type { CaipAssetType } from '@metamask/utils';
5+
import pLimit from 'p-limit';
56

67
import { isStakingContractAssetId } from './evm-rpc-services';
78
import { projectLogger, createModuleLogger } from '../logger';
@@ -19,6 +20,12 @@ import type {
1920

2021
const CONTROLLER_NAME = 'TokenDataSource';
2122

23+
/** Tokens API `/v3/assets` accepts at most this many `assetIds` per request. */
24+
const V3_ASSETS_MAX_IDS_PER_REQUEST = 120;
25+
26+
/** Max concurrent `/v3/assets` chunk requests (same default scale as balance middleware). */
27+
const V3_ASSETS_FETCH_CONCURRENCY = 3;
28+
2229
const MIN_TOKEN_OCCURRENCES = 3;
2330

2431
const log = createModuleLogger(projectLogger, CONTROLLER_NAME);
@@ -243,20 +250,38 @@ export class TokenDataSource {
243250
}
244251

245252
try {
246-
// Use ApiPlatformClient for fetching asset metadata
247-
// API returns an array with assetId as a property on each item
248-
const metadataResponse = await this.#apiClient.tokens.fetchV3Assets(
249-
supportedAssetIds,
250-
{
251-
includeIconUrl: true,
252-
includeMarketData: true,
253-
includeMetadata: true,
254-
includeLabels: true,
255-
includeRwaData: true,
256-
includeAggregators: true,
257-
includeOccurrences: true,
258-
},
253+
const metadataQueryOptions = {
254+
includeIconUrl: true,
255+
includeMarketData: true,
256+
includeMetadata: true,
257+
includeLabels: true,
258+
includeRwaData: true,
259+
includeAggregators: true,
260+
includeOccurrences: true,
261+
};
262+
263+
// API returns an array with assetId as a property on each item.
264+
// Request in chunks to stay within the per-request asset ID limit.
265+
const chunks: string[][] = [];
266+
for (
267+
let i = 0;
268+
i < supportedAssetIds.length;
269+
i += V3_ASSETS_MAX_IDS_PER_REQUEST
270+
) {
271+
chunks.push(
272+
supportedAssetIds.slice(i, i + V3_ASSETS_MAX_IDS_PER_REQUEST),
273+
);
274+
}
275+
276+
const limit = pLimit(V3_ASSETS_FETCH_CONCURRENCY);
277+
const chunkResponses = await Promise.all(
278+
chunks.map((chunk) =>
279+
limit(() =>
280+
this.#apiClient.tokens.fetchV3Assets(chunk, metadataQueryOptions),
281+
),
282+
),
259283
);
284+
const metadataResponse = chunkResponses.flat();
260285

261286
response.assetsInfo ??= {};
262287

0 commit comments

Comments
 (0)