From 374b6fa0c4f34f44525e41f6c966ee377aea0a55 Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Wed, 26 Aug 2026 16:17:53 +0200 Subject: [PATCH 1/2] feat: add batches utils into snap-networks-utils --- packages/snap-networks-utils/CHANGELOG.md | 1 + .../snap-networks-utils/src/async.test.ts | 239 ++++++++++++++++++ packages/snap-networks-utils/src/async.ts | 127 ++++++++++ packages/snap-networks-utils/src/index.ts | 7 + 4 files changed, 374 insertions(+) create mode 100644 packages/snap-networks-utils/src/async.test.ts create mode 100644 packages/snap-networks-utils/src/async.ts diff --git a/packages/snap-networks-utils/CHANGELOG.md b/packages/snap-networks-utils/CHANGELOG.md index e37c204a3..e7d7ce303 100644 --- a/packages/snap-networks-utils/CHANGELOG.md +++ b/packages/snap-networks-utils/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `InFlightCoalescer`, exported from a new `./dedupe` entry point, which coalesces concurrent async operations by key so callers share one in-flight run ([#149](https://github.com/MetaMask/internal-snaps/pull/149)) +- Add shared async batching utilities. ([#211](https://github.com/MetaMask/internal-snaps/pull/211)) - Add origin permission helpers ([#193](https://github.com/MetaMask/internal-snaps/pull/193)) - `createOriginPermissions` for building origin-to-method maps - `validateOrigin` for checking an origin against a permission map diff --git a/packages/snap-networks-utils/src/async.test.ts b/packages/snap-networks-utils/src/async.test.ts new file mode 100644 index 000000000..3fc8d1714 --- /dev/null +++ b/packages/snap-networks-utils/src/async.test.ts @@ -0,0 +1,239 @@ +import { + batchesAll, + batchesAllSettled, + batchesAllSettledWithChunks, + batchesAllWithChunks, + chunks, +} from './async'; + +describe('batchesAll', () => { + it('throws when batchSize is less than 1', async () => { + const run = async () => batchesAll([1], 0, async (value) => value); + await expect(run()).rejects.toThrow(RangeError); + }); + + it('returns empty array for empty items', async () => { + const result = await batchesAll([], 3, async () => 0); + expect(result).toStrictEqual([]); + }); + + it('preserves order and aligns results with items', async () => { + const items = ['a', 'b', 'c']; + const results = await batchesAll(items, 2, async (item) => + item.toUpperCase(), + ); + + expect(results).toStrictEqual(['A', 'B', 'C']); + }); + + it('passes global index to mapper', async () => { + const results = await batchesAll(['x', 'y'], 5, async (_item, i) => i); + expect(results).toStrictEqual([0, 1]); + }); + + it('rejects when any mapper rejects', async () => { + const mapper = jest + .fn() + .mockResolvedValueOnce(10) + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce(30); + + await expect(batchesAll([1, 2, 3], 2, mapper)).rejects.toThrow('boom'); + }); + + it('limits concurrency to batchSize across waves', async () => { + let concurrent = 0; + let maxConcurrent = 0; + const items = [1, 2, 3, 4, 5]; + + await batchesAll(items, 2, async () => { + concurrent += 1; + maxConcurrent = Math.max(maxConcurrent, concurrent); + await new Promise((resolve) => { + setTimeout(resolve, 5); + }); + concurrent -= 1; + return 0; + }); + + expect(maxConcurrent).toBe(2); + }); +}); + +describe('batchesAllSettled', () => { + it('throws when batchSize is less than 1', async () => { + const run = async () => batchesAllSettled([1], 0, async (value) => value); + await expect(run()).rejects.toThrow(RangeError); + }); + + it('returns empty array for empty items', async () => { + const result = await batchesAllSettled([], 3, async () => 0); + expect(result).toStrictEqual([]); + }); + + it('preserves order and aligns results with items', async () => { + const items = ['a', 'b', 'c']; + const settled = await batchesAllSettled(items, 2, async (item) => + item.toUpperCase(), + ); + + expect(settled).toHaveLength(3); + expect(settled[0]).toStrictEqual({ status: 'fulfilled', value: 'A' }); + expect(settled[1]).toStrictEqual({ status: 'fulfilled', value: 'B' }); + expect(settled[2]).toStrictEqual({ status: 'fulfilled', value: 'C' }); + }); + + it('passes global index to mapper', async () => { + const settled = await batchesAllSettled( + ['x', 'y'], + 5, + async (_item, i) => i, + ); + expect(settled[0]).toStrictEqual({ status: 'fulfilled', value: 0 }); + expect(settled[1]).toStrictEqual({ status: 'fulfilled', value: 1 }); + }); + + it('records rejected promises without failing the whole batch', async () => { + const fns = [ + async () => 10, + async () => { + throw new Error('boom'); + }, + async () => 90, + ]; + + const settled = await batchesAllSettled(fns, 2, async (fn) => fn()); + + expect(settled[0]).toStrictEqual({ status: 'fulfilled', value: 10 }); + expect(settled[1]).toMatchObject({ status: 'rejected' }); + expect(settled[2]).toStrictEqual({ status: 'fulfilled', value: 90 }); + }); + + it('limits concurrency to batchSize across waves', async () => { + let concurrent = 0; + let maxConcurrent = 0; + const items = [1, 2, 3, 4, 5]; + + await batchesAllSettled(items, 2, async () => { + concurrent += 1; + maxConcurrent = Math.max(maxConcurrent, concurrent); + await new Promise((resolve) => { + setTimeout(resolve, 5); + }); + concurrent -= 1; + return 0; + }); + + expect(maxConcurrent).toBe(2); + }); +}); + +describe('chunks', () => { + it('returns empty array for empty items', () => { + const result = chunks([], 3); + expect(result).toStrictEqual([]); + }); + + it('returns single chunk for items less than chunk size', () => { + const result = chunks(['a', 'b', 'c'], 4); + expect(result).toStrictEqual([['a', 'b', 'c']]); + }); + + it('returns multiple chunks for items greater than chunk size', () => { + const result = chunks( + ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'], + 3, + ); + expect(result).toStrictEqual([ + ['a', 'b', 'c'], + ['d', 'e', 'f'], + ['g', 'h', 'i'], + ['j'], + ]); + }); + + it('throws when chunkSize is less than 1', () => { + const run = () => chunks(['a', 'b', 'c'], 0); + expect(run).toThrow(RangeError); + }); +}); + +describe('batchesAllSettledWithChunks', () => { + it('returns empty array for empty items', async () => { + const result = await batchesAllSettledWithChunks([], 2, 3, async () => 0); + expect(result).toStrictEqual([]); + }); + + it('maps each chunk and preserves chunk order in settled results', async () => { + const settled = await batchesAllSettledWithChunks( + ['a', 'b', 'c', 'd'], + 2, + 2, + async (chunk, chunkIndex) => ({ chunkIndex, joined: chunk.join('') }), + ); + + expect(settled).toHaveLength(2); + expect(settled[0]).toStrictEqual({ + status: 'fulfilled', + value: { chunkIndex: 0, joined: 'ab' }, + }); + expect(settled[1]).toStrictEqual({ + status: 'fulfilled', + value: { chunkIndex: 1, joined: 'cd' }, + }); + }); + + it('records rejected chunk without failing other chunks', async () => { + const mapper = jest + .fn() + .mockRejectedValueOnce(new Error('chunk0 fail')) + .mockResolvedValueOnce(7); + + const settled = await batchesAllSettledWithChunks( + [1, 2, 3, 4], + 2, + 1, + mapper, + ); + + expect(mapper).toHaveBeenNthCalledWith(1, [1, 2], 0); + expect(mapper).toHaveBeenNthCalledWith(2, [3, 4], 1); + expect(settled[0]).toMatchObject({ + status: 'rejected', + reason: expect.objectContaining({ message: 'chunk0 fail' }), + }); + expect(settled[1]).toStrictEqual({ status: 'fulfilled', value: 7 }); + }); +}); + +describe('batchesAllWithChunks', () => { + it('returns empty array for empty items', async () => { + const result = await batchesAllWithChunks([], 2, 3, async () => 0); + expect(result).toStrictEqual([]); + }); + + it('maps each chunk and preserves chunk order', async () => { + const results = await batchesAllWithChunks( + ['a', 'b', 'c', 'd'], + 2, + 2, + async (chunk, chunkIndex) => ({ chunkIndex, joined: chunk.join('') }), + ); + + expect(results).toStrictEqual([ + { chunkIndex: 0, joined: 'ab' }, + { chunkIndex: 1, joined: 'cd' }, + ]); + }); + + it('rejects when any chunk mapper rejects', async () => { + const mapper = jest + .fn() + .mockRejectedValueOnce(new Error('chunk0 fail')) + .mockResolvedValueOnce(7); + + await expect( + batchesAllWithChunks([1, 2, 3, 4], 2, 1, mapper), + ).rejects.toThrow('chunk0 fail'); + }); +}); diff --git a/packages/snap-networks-utils/src/async.ts b/packages/snap-networks-utils/src/async.ts new file mode 100644 index 000000000..b5197087b --- /dev/null +++ b/packages/snap-networks-utils/src/async.ts @@ -0,0 +1,127 @@ +/** + * Splits items into chunks of a given size. + * + * @param items - Input items; order is preserved in the returned chunks. + * @param chunkSize - Size of each chunk (must be ≥ 1). + * @returns An array of chunks, each containing `chunkSize` items. + */ +export function chunks( + items: readonly TItem[], + chunkSize: number, +): TItem[][] { + if (chunkSize < 1) { + throw new RangeError('chunkSize must be at least 1'); + } + + const itemsChunks: TItem[][] = []; + for (let index = 0; index < items.length; index += chunkSize) { + itemsChunks.push(items.slice(index, index + chunkSize)); + } + return itemsChunks; +} + +/** + * Runs async work on items in fixed-size waves, using {@link Promise.all} per wave. + * The next wave starts only after the current wave completes; any rejection fails the whole call. + * + * @param items - Input items; order is preserved in the returned results. + * @param batchSize - Maximum concurrent mapper invocations per wave (must be ≥ 1). + * @param mapper - Async function for each item; receives the global index in `items`. + * @returns One result per item, in the same order as `items`. + */ +export async function batchesAll( + items: readonly TItem[], + batchSize: number, + mapper: (item: TItem, index: number) => Promise, +): Promise { + if (batchSize < 1) { + throw new RangeError('batchSize must be at least 1'); + } + + const results: TResult[] = []; + + for (let index = 0; index < items.length; index += batchSize) { + const batch = items.slice(index, index + batchSize); + const batchResults = await Promise.all( + batch.map(async (item, batchOffset) => mapper(item, index + batchOffset)), + ); + results.push(...batchResults); + } + + return results; +} + +/** + * Runs async work on items in fixed-size waves, using {@link Promise.allSettled} per wave. + * The next wave starts only after the current one settles, limiting concurrency to `batchSize`. + * + * @param items - Input items; order is preserved in the returned results. + * @param batchSize - Maximum concurrent mapper invocations per wave (must be ≥ 1). + * @param mapper - Async function for each item; receives the global index in `items`. + * @returns One settled result per item, in the same order as `items`. + */ +export async function batchesAllSettled( + items: readonly TItem[], + batchSize: number, + mapper: (item: TItem, index: number) => Promise, +): Promise[]> { + if (batchSize < 1) { + throw new RangeError('batchSize must be at least 1'); + } + + const results: PromiseSettledResult[] = []; + + for (let index = 0; index < items.length; index += batchSize) { + const batch = items.slice(index, index + batchSize); + const settled = await Promise.allSettled( + batch.map(async (item, batchOffset) => mapper(item, index + batchOffset)), + ); + results.push(...settled); + } + + return results; +} + +/** + * Splits `items` into consecutive chunks of `chunkSize`, then runs {@link batchesAllSettled} on those chunks. + * Each mapper call receives one chunk; settled results are in chunk order (same order as {@link chunks}). + * + * @param items - Flat input items. + * @param chunkSize - Items per chunk (must be ≥ 1). + * @param batchSize - Max concurrent chunk mappers per wave (must be ≥ 1). + * @param mapper - Async work for a single chunk; second argument is the chunk index (0-based). + * @returns One settled result per chunk. + */ +export async function batchesAllSettledWithChunks( + items: readonly TItem[], + chunkSize: number, + batchSize: number, + mapper: (chunk: TItem[], chunkIndex: number) => Promise, +): Promise[]> { + const itemChunks = chunks(items, chunkSize); + return batchesAllSettled(itemChunks, batchSize, async (chunk, chunkIndex) => + mapper(chunk, chunkIndex), + ); +} + +/** + * Splits `items` into consecutive chunks of `chunkSize`, then runs {@link batchesAll} on those chunks. + * Each mapper call receives one chunk; results are in chunk order (same order as {@link chunks}). + * + * @param items - Flat input items. + * @param chunkSize - Items per chunk (must be ≥ 1). + * @param batchSize - Max concurrent chunk mappers per wave (must be ≥ 1). + * @param mapper - Async work for a single chunk; second argument is the chunk index (0-based). + * @returns One result per chunk. + */ +export async function batchesAllWithChunks( + items: readonly TItem[], + chunkSize: number, + batchSize: number, + mapper: (chunk: TItem[], chunkIndex: number) => Promise, +): Promise { + const itemChunks = chunks(items, chunkSize); + return batchesAll(itemChunks, batchSize, async (chunk, chunkIndex) => + mapper(chunk, chunkIndex), + ); +} diff --git a/packages/snap-networks-utils/src/index.ts b/packages/snap-networks-utils/src/index.ts index ac22a2e77..2761f560f 100644 --- a/packages/snap-networks-utils/src/index.ts +++ b/packages/snap-networks-utils/src/index.ts @@ -13,6 +13,13 @@ export { buildUrl } from './buildUrl/buildUrl'; export type { BuildUrlParams } from './buildUrl/buildUrl'; export { sanitizeControlCharacters, sanitizeUri } from './sanitize'; export { UrlStruct } from './urlStruct/urlStruct'; +export { + batchesAll, + batchesAllSettled, + batchesAllSettledWithChunks, + batchesAllWithChunks, + chunks, +} from './async'; export { Logger, LogLevel } from './logger'; export type { LoggerOptions, From d8a9b0ea38e82a3e903ca4da864a0798cb9a07c9 Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Wed, 26 Aug 2026 16:21:55 +0200 Subject: [PATCH 2/2] refactor: consolidate snap async utilities --- eslint-suppressions.json | 5 - .../snap-networks-utils/src/async.test.ts | 14 +- .../solana-wallet-snap/snap.manifest.json | 2 +- .../token-api-client/TokenApiClient.test.ts | 52 ++++ .../token-api-client/TokenApiClient.ts | 12 +- .../src/core/utils/retry.ts | 34 --- .../src/utils/async.test.ts | 239 ------------------ .../stellar-wallet-snap/src/utils/async.ts | 127 ---------- .../stellar-wallet-snap/src/utils/index.ts | 8 +- packages/tron-wallet-snap/snap.manifest.json | 2 +- .../clients/token-api/TokenApiClient.test.ts | 44 ++++ .../src/clients/token-api/TokenApiClient.ts | 16 +- 12 files changed, 123 insertions(+), 432 deletions(-) delete mode 100644 packages/solana-wallet-snap/src/core/utils/retry.ts delete mode 100644 packages/stellar-wallet-snap/src/utils/async.test.ts delete mode 100644 packages/stellar-wallet-snap/src/utils/async.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index c178c43f8..18c52902d 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1616,11 +1616,6 @@ "count": 1 } }, - "packages/stellar-wallet-snap/src/utils/async.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 6 - } - }, "packages/stellar-wallet-snap/src/utils/snap.ts": { "no-restricted-syntax": { "count": 1 diff --git a/packages/snap-networks-utils/src/async.test.ts b/packages/snap-networks-utils/src/async.test.ts index 3fc8d1714..ea7979e96 100644 --- a/packages/snap-networks-utils/src/async.test.ts +++ b/packages/snap-networks-utils/src/async.test.ts @@ -8,7 +8,8 @@ import { describe('batchesAll', () => { it('throws when batchSize is less than 1', async () => { - const run = async () => batchesAll([1], 0, async (value) => value); + const run = async (): Promise => + batchesAll([1], 0, async (value) => value); await expect(run()).rejects.toThrow(RangeError); }); @@ -62,7 +63,8 @@ describe('batchesAll', () => { describe('batchesAllSettled', () => { it('throws when batchSize is less than 1', async () => { - const run = async () => batchesAllSettled([1], 0, async (value) => value); + const run = async (): Promise[]> => + batchesAllSettled([1], 0, async (value) => value); await expect(run()).rejects.toThrow(RangeError); }); @@ -95,11 +97,11 @@ describe('batchesAllSettled', () => { it('records rejected promises without failing the whole batch', async () => { const fns = [ - async () => 10, - async () => { + async (): Promise => 10, + async (): Promise => { throw new Error('boom'); }, - async () => 90, + async (): Promise => 90, ]; const settled = await batchesAllSettled(fns, 2, async (fn) => fn()); @@ -153,7 +155,7 @@ describe('chunks', () => { }); it('throws when chunkSize is less than 1', () => { - const run = () => chunks(['a', 'b', 'c'], 0); + const run = (): string[][] => chunks(['a', 'b', 'c'], 0); expect(run).toThrow(RangeError); }); }); diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index e527789b0..c3065e33d 100644 --- a/packages/solana-wallet-snap/snap.manifest.json +++ b/packages/solana-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "mmsPn3F/291/s6sb7cq9t9PbNYFCSk26kQ9bIpw54r8=", + "shasum": "xVsSMBagSbDp3D78uRkhm+EK0pwvxWvEWEH8rByadhY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/solana-wallet-snap/src/core/clients/token-api-client/TokenApiClient.test.ts b/packages/solana-wallet-snap/src/core/clients/token-api-client/TokenApiClient.test.ts index f8a426bc7..ef5eacf80 100644 --- a/packages/solana-wallet-snap/src/core/clients/token-api-client/TokenApiClient.test.ts +++ b/packages/solana-wallet-snap/src/core/clients/token-api-client/TokenApiClient.test.ts @@ -128,6 +128,58 @@ describe('TokenApiClient', () => { await client.getTokensMetadata(tokenAddresses); expect(mockFetch).toHaveBeenCalledTimes(2); + expect( + mockFetch.mock.calls.map(([url]) => + new URL(url as string).searchParams.get('assetIds')?.split(','), + ), + ).toStrictEqual([tokenAddresses.slice(0, 50), tokenAddresses.slice(50)]); + }); + + it('merges metadata returned from consecutive chunks', async () => { + const thirdTokenAddress = tokenAddressToCaip19( + Network.Mainnet, + '9GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', + ); + const tokenAddresses = [ + tokenAddressToCaip19( + Network.Mainnet, + '1GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', + ), + tokenAddressToCaip19( + Network.Mainnet, + '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', + ), + thirdTokenAddress, + ]; + const chunkedConfigProvider = { + get: jest.fn().mockReturnValue({ + tokenApi: { baseUrl: 'https://some-mock-url.com', chunkSize: 2 }, + staticApi: { baseUrl: 'https://some-mock-static-url.com' }, + }), + } as unknown as ConfigProvider; + client = new TokenApiClient(chunkedConfigProvider, mockFetch, mockLogger); + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(MOCK_METADATA_RESPONSE), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce([ + { + decimals: 6, + assetId: thirdTokenAddress, + name: 'Popcat 3', + symbol: 'POPCAT3', + }, + ]), + }); + + const metadata = await client.getTokensMetadata(tokenAddresses); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(Object.keys(metadata)).toStrictEqual(tokenAddresses); + expect(metadata[thirdTokenAddress]?.name).toBe('Popcat 3'); }); it('rejects caip19Ids that are invalid', async () => { diff --git a/packages/solana-wallet-snap/src/core/clients/token-api-client/TokenApiClient.ts b/packages/solana-wallet-snap/src/core/clients/token-api-client/TokenApiClient.ts index a91cbc757..cd0544f58 100644 --- a/packages/solana-wallet-snap/src/core/clients/token-api-client/TokenApiClient.ts +++ b/packages/solana-wallet-snap/src/core/clients/token-api-client/TokenApiClient.ts @@ -1,4 +1,4 @@ -import { UrlStruct, buildUrl } from '@metamask/snap-networks-utils'; +import { UrlStruct, buildUrl, chunks } from '@metamask/snap-networks-utils'; import type { Logger } from '@metamask/snap-networks-utils'; import type { FungibleAssetMetadata } from '@metamask/snaps-sdk'; import { array, assert } from '@metamask/superstruct'; @@ -99,16 +99,14 @@ export class TokenApiClient { ); } - // Split addresses into chunks - const chunks: TokenCaipAssetType[][] = []; - for (let i = 0; i < supportedAssetTypes.length; i += this.#chunkSize) { - chunks.push(supportedAssetTypes.slice(i, i + this.#chunkSize)); - } + const assetTypeChunks = chunks(supportedAssetTypes, this.#chunkSize); // Fetch metadata for each chunk const tokenMetadataResponses = ( await Promise.all( - chunks.map(async (chunk) => this.#fetchTokenMetadataBatch(chunk)), + assetTypeChunks.map(async (chunk) => + this.#fetchTokenMetadataBatch(chunk), + ), ) ).flat(); diff --git a/packages/solana-wallet-snap/src/core/utils/retry.ts b/packages/solana-wallet-snap/src/core/utils/retry.ts deleted file mode 100644 index 940a0d3ab..000000000 --- a/packages/solana-wallet-snap/src/core/utils/retry.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Retry the passed promise until it resolves, retrying with a delay - * between attempts until the maximum number of attempts is reached. - * - * @param fn - The promise to retry. - * @param options - The options for the promise. - * @param options.maxAttempts - The maximum number of attempts. - * @param options.delayMs - The delay between attempts in milliseconds. - * @returns The result of the promise. - */ -export async function retry( - fn: () => Promise | TResult, - options?: { - maxAttempts?: number; - delayMs?: number; - }, -): Promise { - const maxAttempts = options?.maxAttempts ?? 10; - const delayMs = options?.delayMs ?? 1000; - - let attempts = 0; - while (attempts < maxAttempts) { - try { - return await fn(); - } catch (error) { - attempts += 1; - if (attempts === maxAttempts) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - } - throw new Error('Unreachable'); -} diff --git a/packages/stellar-wallet-snap/src/utils/async.test.ts b/packages/stellar-wallet-snap/src/utils/async.test.ts deleted file mode 100644 index 3fc8d1714..000000000 --- a/packages/stellar-wallet-snap/src/utils/async.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { - batchesAll, - batchesAllSettled, - batchesAllSettledWithChunks, - batchesAllWithChunks, - chunks, -} from './async'; - -describe('batchesAll', () => { - it('throws when batchSize is less than 1', async () => { - const run = async () => batchesAll([1], 0, async (value) => value); - await expect(run()).rejects.toThrow(RangeError); - }); - - it('returns empty array for empty items', async () => { - const result = await batchesAll([], 3, async () => 0); - expect(result).toStrictEqual([]); - }); - - it('preserves order and aligns results with items', async () => { - const items = ['a', 'b', 'c']; - const results = await batchesAll(items, 2, async (item) => - item.toUpperCase(), - ); - - expect(results).toStrictEqual(['A', 'B', 'C']); - }); - - it('passes global index to mapper', async () => { - const results = await batchesAll(['x', 'y'], 5, async (_item, i) => i); - expect(results).toStrictEqual([0, 1]); - }); - - it('rejects when any mapper rejects', async () => { - const mapper = jest - .fn() - .mockResolvedValueOnce(10) - .mockRejectedValueOnce(new Error('boom')) - .mockResolvedValueOnce(30); - - await expect(batchesAll([1, 2, 3], 2, mapper)).rejects.toThrow('boom'); - }); - - it('limits concurrency to batchSize across waves', async () => { - let concurrent = 0; - let maxConcurrent = 0; - const items = [1, 2, 3, 4, 5]; - - await batchesAll(items, 2, async () => { - concurrent += 1; - maxConcurrent = Math.max(maxConcurrent, concurrent); - await new Promise((resolve) => { - setTimeout(resolve, 5); - }); - concurrent -= 1; - return 0; - }); - - expect(maxConcurrent).toBe(2); - }); -}); - -describe('batchesAllSettled', () => { - it('throws when batchSize is less than 1', async () => { - const run = async () => batchesAllSettled([1], 0, async (value) => value); - await expect(run()).rejects.toThrow(RangeError); - }); - - it('returns empty array for empty items', async () => { - const result = await batchesAllSettled([], 3, async () => 0); - expect(result).toStrictEqual([]); - }); - - it('preserves order and aligns results with items', async () => { - const items = ['a', 'b', 'c']; - const settled = await batchesAllSettled(items, 2, async (item) => - item.toUpperCase(), - ); - - expect(settled).toHaveLength(3); - expect(settled[0]).toStrictEqual({ status: 'fulfilled', value: 'A' }); - expect(settled[1]).toStrictEqual({ status: 'fulfilled', value: 'B' }); - expect(settled[2]).toStrictEqual({ status: 'fulfilled', value: 'C' }); - }); - - it('passes global index to mapper', async () => { - const settled = await batchesAllSettled( - ['x', 'y'], - 5, - async (_item, i) => i, - ); - expect(settled[0]).toStrictEqual({ status: 'fulfilled', value: 0 }); - expect(settled[1]).toStrictEqual({ status: 'fulfilled', value: 1 }); - }); - - it('records rejected promises without failing the whole batch', async () => { - const fns = [ - async () => 10, - async () => { - throw new Error('boom'); - }, - async () => 90, - ]; - - const settled = await batchesAllSettled(fns, 2, async (fn) => fn()); - - expect(settled[0]).toStrictEqual({ status: 'fulfilled', value: 10 }); - expect(settled[1]).toMatchObject({ status: 'rejected' }); - expect(settled[2]).toStrictEqual({ status: 'fulfilled', value: 90 }); - }); - - it('limits concurrency to batchSize across waves', async () => { - let concurrent = 0; - let maxConcurrent = 0; - const items = [1, 2, 3, 4, 5]; - - await batchesAllSettled(items, 2, async () => { - concurrent += 1; - maxConcurrent = Math.max(maxConcurrent, concurrent); - await new Promise((resolve) => { - setTimeout(resolve, 5); - }); - concurrent -= 1; - return 0; - }); - - expect(maxConcurrent).toBe(2); - }); -}); - -describe('chunks', () => { - it('returns empty array for empty items', () => { - const result = chunks([], 3); - expect(result).toStrictEqual([]); - }); - - it('returns single chunk for items less than chunk size', () => { - const result = chunks(['a', 'b', 'c'], 4); - expect(result).toStrictEqual([['a', 'b', 'c']]); - }); - - it('returns multiple chunks for items greater than chunk size', () => { - const result = chunks( - ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'], - 3, - ); - expect(result).toStrictEqual([ - ['a', 'b', 'c'], - ['d', 'e', 'f'], - ['g', 'h', 'i'], - ['j'], - ]); - }); - - it('throws when chunkSize is less than 1', () => { - const run = () => chunks(['a', 'b', 'c'], 0); - expect(run).toThrow(RangeError); - }); -}); - -describe('batchesAllSettledWithChunks', () => { - it('returns empty array for empty items', async () => { - const result = await batchesAllSettledWithChunks([], 2, 3, async () => 0); - expect(result).toStrictEqual([]); - }); - - it('maps each chunk and preserves chunk order in settled results', async () => { - const settled = await batchesAllSettledWithChunks( - ['a', 'b', 'c', 'd'], - 2, - 2, - async (chunk, chunkIndex) => ({ chunkIndex, joined: chunk.join('') }), - ); - - expect(settled).toHaveLength(2); - expect(settled[0]).toStrictEqual({ - status: 'fulfilled', - value: { chunkIndex: 0, joined: 'ab' }, - }); - expect(settled[1]).toStrictEqual({ - status: 'fulfilled', - value: { chunkIndex: 1, joined: 'cd' }, - }); - }); - - it('records rejected chunk without failing other chunks', async () => { - const mapper = jest - .fn() - .mockRejectedValueOnce(new Error('chunk0 fail')) - .mockResolvedValueOnce(7); - - const settled = await batchesAllSettledWithChunks( - [1, 2, 3, 4], - 2, - 1, - mapper, - ); - - expect(mapper).toHaveBeenNthCalledWith(1, [1, 2], 0); - expect(mapper).toHaveBeenNthCalledWith(2, [3, 4], 1); - expect(settled[0]).toMatchObject({ - status: 'rejected', - reason: expect.objectContaining({ message: 'chunk0 fail' }), - }); - expect(settled[1]).toStrictEqual({ status: 'fulfilled', value: 7 }); - }); -}); - -describe('batchesAllWithChunks', () => { - it('returns empty array for empty items', async () => { - const result = await batchesAllWithChunks([], 2, 3, async () => 0); - expect(result).toStrictEqual([]); - }); - - it('maps each chunk and preserves chunk order', async () => { - const results = await batchesAllWithChunks( - ['a', 'b', 'c', 'd'], - 2, - 2, - async (chunk, chunkIndex) => ({ chunkIndex, joined: chunk.join('') }), - ); - - expect(results).toStrictEqual([ - { chunkIndex: 0, joined: 'ab' }, - { chunkIndex: 1, joined: 'cd' }, - ]); - }); - - it('rejects when any chunk mapper rejects', async () => { - const mapper = jest - .fn() - .mockRejectedValueOnce(new Error('chunk0 fail')) - .mockResolvedValueOnce(7); - - await expect( - batchesAllWithChunks([1, 2, 3, 4], 2, 1, mapper), - ).rejects.toThrow('chunk0 fail'); - }); -}); diff --git a/packages/stellar-wallet-snap/src/utils/async.ts b/packages/stellar-wallet-snap/src/utils/async.ts deleted file mode 100644 index b5197087b..000000000 --- a/packages/stellar-wallet-snap/src/utils/async.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Splits items into chunks of a given size. - * - * @param items - Input items; order is preserved in the returned chunks. - * @param chunkSize - Size of each chunk (must be ≥ 1). - * @returns An array of chunks, each containing `chunkSize` items. - */ -export function chunks( - items: readonly TItem[], - chunkSize: number, -): TItem[][] { - if (chunkSize < 1) { - throw new RangeError('chunkSize must be at least 1'); - } - - const itemsChunks: TItem[][] = []; - for (let index = 0; index < items.length; index += chunkSize) { - itemsChunks.push(items.slice(index, index + chunkSize)); - } - return itemsChunks; -} - -/** - * Runs async work on items in fixed-size waves, using {@link Promise.all} per wave. - * The next wave starts only after the current wave completes; any rejection fails the whole call. - * - * @param items - Input items; order is preserved in the returned results. - * @param batchSize - Maximum concurrent mapper invocations per wave (must be ≥ 1). - * @param mapper - Async function for each item; receives the global index in `items`. - * @returns One result per item, in the same order as `items`. - */ -export async function batchesAll( - items: readonly TItem[], - batchSize: number, - mapper: (item: TItem, index: number) => Promise, -): Promise { - if (batchSize < 1) { - throw new RangeError('batchSize must be at least 1'); - } - - const results: TResult[] = []; - - for (let index = 0; index < items.length; index += batchSize) { - const batch = items.slice(index, index + batchSize); - const batchResults = await Promise.all( - batch.map(async (item, batchOffset) => mapper(item, index + batchOffset)), - ); - results.push(...batchResults); - } - - return results; -} - -/** - * Runs async work on items in fixed-size waves, using {@link Promise.allSettled} per wave. - * The next wave starts only after the current one settles, limiting concurrency to `batchSize`. - * - * @param items - Input items; order is preserved in the returned results. - * @param batchSize - Maximum concurrent mapper invocations per wave (must be ≥ 1). - * @param mapper - Async function for each item; receives the global index in `items`. - * @returns One settled result per item, in the same order as `items`. - */ -export async function batchesAllSettled( - items: readonly TItem[], - batchSize: number, - mapper: (item: TItem, index: number) => Promise, -): Promise[]> { - if (batchSize < 1) { - throw new RangeError('batchSize must be at least 1'); - } - - const results: PromiseSettledResult[] = []; - - for (let index = 0; index < items.length; index += batchSize) { - const batch = items.slice(index, index + batchSize); - const settled = await Promise.allSettled( - batch.map(async (item, batchOffset) => mapper(item, index + batchOffset)), - ); - results.push(...settled); - } - - return results; -} - -/** - * Splits `items` into consecutive chunks of `chunkSize`, then runs {@link batchesAllSettled} on those chunks. - * Each mapper call receives one chunk; settled results are in chunk order (same order as {@link chunks}). - * - * @param items - Flat input items. - * @param chunkSize - Items per chunk (must be ≥ 1). - * @param batchSize - Max concurrent chunk mappers per wave (must be ≥ 1). - * @param mapper - Async work for a single chunk; second argument is the chunk index (0-based). - * @returns One settled result per chunk. - */ -export async function batchesAllSettledWithChunks( - items: readonly TItem[], - chunkSize: number, - batchSize: number, - mapper: (chunk: TItem[], chunkIndex: number) => Promise, -): Promise[]> { - const itemChunks = chunks(items, chunkSize); - return batchesAllSettled(itemChunks, batchSize, async (chunk, chunkIndex) => - mapper(chunk, chunkIndex), - ); -} - -/** - * Splits `items` into consecutive chunks of `chunkSize`, then runs {@link batchesAll} on those chunks. - * Each mapper call receives one chunk; results are in chunk order (same order as {@link chunks}). - * - * @param items - Flat input items. - * @param chunkSize - Items per chunk (must be ≥ 1). - * @param batchSize - Max concurrent chunk mappers per wave (must be ≥ 1). - * @param mapper - Async work for a single chunk; second argument is the chunk index (0-based). - * @returns One result per chunk. - */ -export async function batchesAllWithChunks( - items: readonly TItem[], - chunkSize: number, - batchSize: number, - mapper: (chunk: TItem[], chunkIndex: number) => Promise, -): Promise { - const itemChunks = chunks(items, chunkSize); - return batchesAll(itemChunks, batchSize, async (chunk, chunkIndex) => - mapper(chunk, chunkIndex), - ); -} diff --git a/packages/stellar-wallet-snap/src/utils/index.ts b/packages/stellar-wallet-snap/src/utils/index.ts index b75233be5..a64862efa 100644 --- a/packages/stellar-wallet-snap/src/utils/index.ts +++ b/packages/stellar-wallet-snap/src/utils/index.ts @@ -7,7 +7,13 @@ export * from './serialization'; export * from './number'; export * from './caip'; export * from './buffer'; -export * from './async'; +export { + batchesAll, + batchesAllSettled, + batchesAllSettledWithChunks, + batchesAllWithChunks, + chunks, +} from '@metamask/snap-networks-utils'; export * from './assert'; export * from './array'; export * from './i18n'; diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index e67081d00..bf90464a9 100644 --- a/packages/tron-wallet-snap/snap.manifest.json +++ b/packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "JjgAZZwXVK0y4gNHI1erLqSZV5tyn2n5xgbJl7mFT60=", + "shasum": "W7oo+4KV26sALyE4TL49MrZn3zdlj4H6SigPDYlmCrg=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts b/packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts index f68d0b8da..f7463616f 100644 --- a/packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts +++ b/packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts @@ -119,6 +119,50 @@ describe('TokenApiClient', () => { await client.getTokensMetadata(tokenAddresses); expect(mockFetch).toHaveBeenCalledTimes(2); + expect( + mockFetch.mock.calls.map(([url]) => + new URL(url as string).searchParams.get('assetIds')?.split(','), + ), + ).toStrictEqual([tokenAddresses.slice(0, 50), tokenAddresses.slice(50)]); + }); + + it('merges metadata returned from consecutive chunks', async () => { + const thirdTokenAddress = + `${Networks[Network.Mainnet].caip2Id}/trc20:THirdTokenAddressForChunkingTest` as TokenCaipAssetType; + const tokenAddresses = [ + `${Networks[Network.Mainnet].caip2Id}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as TokenCaipAssetType, + `${Networks[Network.Mainnet].caip2Id}/trc20:TUpMhErZL2fhh4sVNULAbNKLokS4GjC1F4` as TokenCaipAssetType, + thirdTokenAddress, + ]; + const chunkedConfigProvider = { + get: jest.fn().mockReturnValue({ + tokenApi: { baseUrl: 'https://some-mock-url.com', chunkSize: 2 }, + staticApi: { baseUrl: 'https://some-mock-static-url.com' }, + }), + } as unknown as ConfigProvider; + client = new TokenApiClient(chunkedConfigProvider, mockFetch, mockLogger); + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(MOCK_METADATA_RESPONSE), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce([ + { + decimals: 6, + assetId: thirdTokenAddress, + name: 'Third token', + symbol: 'THIRD', + }, + ]), + }); + + const metadata = await client.getTokensMetadata(tokenAddresses); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(Object.keys(metadata)).toStrictEqual(tokenAddresses); + expect(metadata[thirdTokenAddress]?.name).toBe('Third token'); }); it('rejects caip19Ids that are invalid', async () => { diff --git a/packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts b/packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts index b1904a721..f9e7494a3 100644 --- a/packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts +++ b/packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts @@ -1,4 +1,4 @@ -import { UrlStruct, buildUrl } from '@metamask/snap-networks-utils'; +import { UrlStruct, buildUrl, chunks } from '@metamask/snap-networks-utils'; import type { Logger } from '@metamask/snap-networks-utils'; import type { FungibleAssetMetadata } from '@metamask/snaps-sdk'; import { array, assert } from '@metamask/superstruct'; @@ -106,20 +106,14 @@ export class TokenApiClient { ); } - // Split addresses into chunks - const chunks: TokenCaipAssetType[][] = []; - for ( - let index = 0; - index < supportedAssetTypes.length; - index += this.#chunkSize - ) { - chunks.push(supportedAssetTypes.slice(index, index + this.#chunkSize)); - } + const assetTypeChunks = chunks(supportedAssetTypes, this.#chunkSize); // Fetch metadata for each chunk const tokenMetadataResponses = ( await Promise.all( - chunks.map(async (chunk) => this.#fetchTokenMetadataBatch(chunk)), + assetTypeChunks.map(async (chunk) => + this.#fetchTokenMetadataBatch(chunk), + ), ) ).flat();