Skip to content

Commit 721d964

Browse files
authored
fix(multichain-network-controller): batch requests for active networks (#5752)
## Explanation This PR implements request batching for the Network Activity API to accommodate a new limitation imposed by the API platform team. The API endpoint now caps requests at 20 account IDs per call to prevent URL length limitations in some browsers. ### Key Changes - Modified `MultichainNetworkService` to handle batching of account IDs in groups of 20 - Added internal batch processing logic to maintain the same public interface - Updated tests to validate correct batching behavior ## References Related to [#4469](MetaMask/MetaMask-planning#4469) ## Changelog NA ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes
1 parent a9df725 commit 721d964

File tree

5 files changed

+162
-4
lines changed

5 files changed

+162
-4
lines changed

packages/multichain-network-controller/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
### Changed
2020

2121
- Updated to restrict `getNetworksWithTransactionActivityByAccounts` to EVM networks only while non-EVM network endpoint support is being completed. Full multi-chain support will be restored in the coming weeks ([#5677](https://github.com/MetaMask/core/pull/5677))
22+
- Updated network activity API requests to have batching support to handle URL length limitations, allowing the controller to fetch network activity for any number of accounts ([#5752](https://github.com/MetaMask/core/pull/5752))
2223

2324
## [0.5.0]
2425

packages/multichain-network-controller/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,16 @@
5353
"@metamask/keyring-internal-api": "^6.0.1",
5454
"@metamask/superstruct": "^3.1.0",
5555
"@metamask/utils": "^11.2.0",
56-
"@solana/addresses": "^2.0.0"
56+
"@solana/addresses": "^2.0.0",
57+
"lodash": "^4.17.21"
5758
},
5859
"devDependencies": {
5960
"@metamask/accounts-controller": "^28.0.0",
6061
"@metamask/auto-changelog": "^3.4.4",
6162
"@metamask/keyring-controller": "^21.0.6",
6263
"@metamask/network-controller": "^23.3.0",
6364
"@types/jest": "^27.4.1",
65+
"@types/lodash": "^4.14.191",
6466
"@types/uuid": "^8.3.0",
6567
"deepmerge": "^4.2.2",
6668
"immer": "^9.0.6",

packages/multichain-network-controller/src/MultichainNetworkService/MultichainNetworkService.test.ts

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { KnownCaipNamespace, type CaipAccountId } from '@metamask/utils';
2+
import { chunk } from 'lodash';
23

34
import { MultichainNetworkService } from './MultichainNetworkService';
45
import {
@@ -9,10 +10,15 @@ import {
910
} from '../api/accounts-api';
1011

1112
describe('MultichainNetworkService', () => {
13+
beforeEach(() => {
14+
jest.resetAllMocks();
15+
});
16+
1217
const mockFetch = jest.fn();
1318
const MOCK_EVM_ADDRESS = '0x1234567890123456789012345678901234567890';
1419
const MOCK_EVM_CHAIN_1 = '1';
1520
const MOCK_EVM_CHAIN_137 = '137';
21+
const DEFAULT_BATCH_SIZE = 20;
1622
const validAccountIds: CaipAccountId[] = [
1723
`${KnownCaipNamespace.Eip155}:${MOCK_EVM_CHAIN_1}:${MOCK_EVM_ADDRESS}`,
1824
`${KnownCaipNamespace.Eip155}:${MOCK_EVM_CHAIN_137}:${MOCK_EVM_ADDRESS}`,
@@ -25,10 +31,30 @@ describe('MultichainNetworkService', () => {
2531
});
2632
expect(service).toBeInstanceOf(MultichainNetworkService);
2733
});
34+
35+
it('accepts a custom batch size', () => {
36+
const customBatchSize = 10;
37+
const service = new MultichainNetworkService({
38+
fetch: mockFetch,
39+
batchSize: customBatchSize,
40+
});
41+
expect(service).toBeInstanceOf(MultichainNetworkService);
42+
});
2843
});
2944

3045
describe('fetchNetworkActivity', () => {
31-
it('makes request with correct URL and headers', async () => {
46+
it('returns empty response for empty account list without making network requests', async () => {
47+
const service = new MultichainNetworkService({
48+
fetch: mockFetch,
49+
});
50+
51+
const result = await service.fetchNetworkActivity([]);
52+
53+
expect(mockFetch).not.toHaveBeenCalled();
54+
expect(result).toStrictEqual({ activeNetworks: [] });
55+
});
56+
57+
it('makes request with correct URL and headers for single batch', async () => {
3258
const mockResponse: ActiveNetworksResponse = {
3359
activeNetworks: [
3460
`${KnownCaipNamespace.Eip155}:${MOCK_EVM_CHAIN_1}:${MOCK_EVM_ADDRESS}`,
@@ -59,6 +85,90 @@ describe('MultichainNetworkService', () => {
5985
expect(result).toStrictEqual(mockResponse);
6086
});
6187

88+
it('batches requests when account IDs exceed the default batch size', async () => {
89+
const manyAccountIds: CaipAccountId[] = [];
90+
for (let i = 1; i <= 30; i++) {
91+
manyAccountIds.push(
92+
`${KnownCaipNamespace.Eip155}:${i}:${MOCK_EVM_ADDRESS}` as CaipAccountId,
93+
);
94+
}
95+
96+
const batches = chunk(manyAccountIds, DEFAULT_BATCH_SIZE);
97+
98+
const firstBatchResponse = {
99+
activeNetworks: batches[0],
100+
};
101+
const secondBatchResponse = {
102+
activeNetworks: batches[1],
103+
};
104+
105+
mockFetch
106+
.mockResolvedValueOnce({
107+
ok: true,
108+
json: () => Promise.resolve(firstBatchResponse),
109+
})
110+
.mockResolvedValue({
111+
ok: true,
112+
json: () => Promise.resolve(secondBatchResponse),
113+
});
114+
115+
const service = new MultichainNetworkService({
116+
fetch: mockFetch,
117+
});
118+
119+
const result = await service.fetchNetworkActivity(manyAccountIds);
120+
121+
expect(mockFetch).toHaveBeenCalledTimes(2);
122+
123+
for (const accountId of manyAccountIds) {
124+
expect(result.activeNetworks).toContain(accountId);
125+
}
126+
});
127+
128+
it('batches requests with custom batch size', async () => {
129+
const customBatchSize = 10;
130+
const manyAccountIds: CaipAccountId[] = [];
131+
for (let i = 1; i <= 30; i++) {
132+
manyAccountIds.push(
133+
`${KnownCaipNamespace.Eip155}:${i}:${MOCK_EVM_ADDRESS}` as CaipAccountId,
134+
);
135+
}
136+
137+
const batches = chunk(manyAccountIds, customBatchSize);
138+
expect(batches).toHaveLength(3);
139+
140+
const batchResponses = batches.map((batch) => ({
141+
activeNetworks: batch,
142+
}));
143+
144+
mockFetch
145+
.mockResolvedValueOnce({
146+
ok: true,
147+
json: () => Promise.resolve(batchResponses[0]),
148+
})
149+
.mockResolvedValueOnce({
150+
ok: true,
151+
json: () => Promise.resolve(batchResponses[1]),
152+
})
153+
.mockResolvedValueOnce({
154+
ok: true,
155+
json: () => Promise.resolve(batchResponses[2]),
156+
});
157+
158+
const service = new MultichainNetworkService({
159+
fetch: mockFetch,
160+
batchSize: customBatchSize,
161+
});
162+
163+
const result = await service.fetchNetworkActivity(manyAccountIds);
164+
165+
expect(mockFetch).toHaveBeenCalledTimes(3);
166+
167+
for (const accountId of manyAccountIds) {
168+
expect(result.activeNetworks).toContain(accountId);
169+
}
170+
});
171+
62172
it('throws error for non-200 response', async () => {
63173
mockFetch.mockResolvedValueOnce({
64174
ok: false,

packages/multichain-network-controller/src/MultichainNetworkService/MultichainNetworkService.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { assert } from '@metamask/superstruct';
22
import type { CaipAccountId } from '@metamask/utils';
3+
import { chunk } from 'lodash';
34

45
import {
56
type ActiveNetworksResponse,
@@ -15,19 +16,61 @@ import {
1516
export class MultichainNetworkService {
1617
readonly #fetch: typeof fetch;
1718

18-
constructor({ fetch: fetchFunction }: { fetch: typeof fetch }) {
19+
readonly #batchSize: number;
20+
21+
constructor({
22+
fetch: fetchFunction,
23+
batchSize,
24+
}: {
25+
fetch: typeof fetch;
26+
batchSize?: number;
27+
}) {
1928
this.#fetch = fetchFunction;
29+
this.#batchSize = batchSize ?? 20;
2030
}
2131

2232
/**
2333
* Fetches active networks for the given account IDs.
34+
* Automatically handles batching requests to comply with URL length limitations.
2435
*
2536
* @param accountIds - Array of CAIP-10 account IDs to fetch activity for.
26-
* @returns Promise resolving to the active networks response.
37+
* @returns Promise resolving to the combined active networks response.
2738
* @throws Error if the response format is invalid or the request fails.
2839
*/
2940
async fetchNetworkActivity(
3041
accountIds: CaipAccountId[],
42+
): Promise<ActiveNetworksResponse> {
43+
if (accountIds.length === 0) {
44+
return { activeNetworks: [] };
45+
}
46+
47+
if (accountIds.length <= this.#batchSize) {
48+
return this.#fetchNetworkActivityBatch(accountIds);
49+
}
50+
51+
const batches = chunk(accountIds, this.#batchSize);
52+
const batchResults = await Promise.all(
53+
batches.map((batch) => this.#fetchNetworkActivityBatch(batch)),
54+
);
55+
56+
const combinedResponse: ActiveNetworksResponse = {
57+
activeNetworks: batchResults.flatMap(
58+
(response) => response.activeNetworks,
59+
),
60+
};
61+
62+
return combinedResponse;
63+
}
64+
65+
/**
66+
* Internal method to fetch a single batch of account IDs.
67+
*
68+
* @param accountIds - Batch of account IDs to fetch
69+
* @returns Promise resolving to the active networks response for this batch
70+
* @throws Error if the response format is invalid or the request fails
71+
*/
72+
async #fetchNetworkActivityBatch(
73+
accountIds: CaipAccountId[],
3174
): Promise<ActiveNetworksResponse> {
3275
try {
3376
const url = buildActiveNetworksUrl(accountIds);

yarn.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3709,10 +3709,12 @@ __metadata:
37093709
"@metamask/utils": "npm:^11.2.0"
37103710
"@solana/addresses": "npm:^2.0.0"
37113711
"@types/jest": "npm:^27.4.1"
3712+
"@types/lodash": "npm:^4.14.191"
37123713
"@types/uuid": "npm:^8.3.0"
37133714
deepmerge: "npm:^4.2.2"
37143715
immer: "npm:^9.0.6"
37153716
jest: "npm:^27.5.1"
3717+
lodash: "npm:^4.17.21"
37163718
nock: "npm:^13.3.1"
37173719
ts-jest: "npm:^27.1.4"
37183720
typedoc: "npm:^0.24.8"

0 commit comments

Comments
 (0)