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/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/stellar-wallet-snap/src/utils/async.test.ts b/packages/snap-networks-utils/src/async.test.ts similarity index 94% rename from packages/stellar-wallet-snap/src/utils/async.test.ts rename to packages/snap-networks-utils/src/async.test.ts index 3fc8d1714..ea7979e96 100644 --- a/packages/stellar-wallet-snap/src/utils/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/stellar-wallet-snap/src/utils/async.ts b/packages/snap-networks-utils/src/async.ts similarity index 100% rename from packages/stellar-wallet-snap/src/utils/async.ts rename to packages/snap-networks-utils/src/async.ts 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, 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/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();