From f7c5d62e338beae3fa83ecdf983d172143e0063f Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 8 Jul 2026 10:37:56 +0200 Subject: [PATCH 1/6] refactor(assets-controller): memoize balance call encodings in MulticallClient Cache encodeBalanceOf and encodeGetEthBalance per account address to avoid redundant ABI encoding when batching many tokens for the same wallet. --- .../clients/MulticallClient.test.ts | 37 ++++++ .../clients/MulticallClient.ts | 105 +++++++++++++++--- 2 files changed, 125 insertions(+), 17 deletions(-) diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.test.ts index 9e532fb3c9c..a25bdab107f 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.test.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.test.ts @@ -1,4 +1,5 @@ import { defaultAbiCoder, Interface } from '@ethersproject/abi'; +import * as controllerUtils from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; import type { Address, BalanceOfRequest, ChainId, Provider } from '../types'; @@ -212,6 +213,42 @@ describe('MulticallClient', () => { expect(result[1].balance).toBe('2000000000'); }); + it('encodes balance call data once per account address in a batch', async () => { + const encodeSpy = jest.spyOn(controllerUtils, 'encodeFunctionData'); + const otherAccount: Address = + '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Address; + + const requests: BalanceOfRequest[] = [ + { tokenAddress: TEST_TOKEN_1, accountAddress: TEST_ACCOUNT }, + { tokenAddress: TEST_TOKEN_2, accountAddress: TEST_ACCOUNT }, + { tokenAddress: TEST_TOKEN_1, accountAddress: otherAccount }, + { tokenAddress: ZERO_ADDRESS, accountAddress: TEST_ACCOUNT }, + ]; + + const mockResponse = buildMockAggregate3Response([ + { success: true, balance: '1000000000' }, + { success: true, balance: '2000000000' }, + { success: true, balance: '3000000000' }, + { success: true, balance: '1000000000000000000' }, + ]); + + mockProvider.call.mockResolvedValue(mockResponse); + + await client.batchBalanceOf(MAINNET_CHAIN_ID, requests); + + const balanceOfEncodings = encodeSpy.mock.calls.filter( + ([, method]) => method === 'balanceOf', + ); + const getEthBalanceEncodings = encodeSpy.mock.calls.filter( + ([, method]) => method === 'getEthBalance', + ); + + expect(balanceOfEncodings).toHaveLength(2); + expect(getEthBalanceEncodings).toHaveLength(1); + + encodeSpy.mockRestore(); + }); + it('should fetch native token balance using getEthBalance', async () => { const requests: BalanceOfRequest[] = [ { tokenAddress: ZERO_ADDRESS, accountAddress: TEST_ACCOUNT }, diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.ts index c7d866444f5..6056902cfc1 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.ts @@ -402,6 +402,72 @@ function encodeGetEthBalance(accountAddress: Address): Hex { ]); } +type BalanceCallDataCache = { + getErc20BalanceCallData: (accountAddress: Address) => Hex; + getNativeBalanceCallData: (accountAddress: Address) => Hex; +}; + +/** + * Cache balance call encodings per account address for a single batch request. + * ERC-20 `balanceOf` and native `getEthBalance` call data depend only on the + * account being queried, not the token contract (which is the multicall target). + * + * @returns A cache scoped to one `batchBalanceOf` invocation. + */ +function createBalanceCallDataCache(): BalanceCallDataCache { + const erc20ByAccount = new Map(); + const nativeByAccount = new Map(); + + return { + getErc20BalanceCallData(accountAddress: Address): Hex { + const key = accountAddress.toLowerCase(); + const existing = erc20ByAccount.get(key); + if (existing !== undefined) { + return existing; + } + const callData = encodeBalanceOf(accountAddress); + erc20ByAccount.set(key, callData); + return callData; + }, + getNativeBalanceCallData(accountAddress: Address): Hex { + const key = accountAddress.toLowerCase(); + const existing = nativeByAccount.get(key); + if (existing !== undefined) { + return existing; + } + const callData = encodeGetEthBalance(accountAddress); + nativeByAccount.set(key, callData); + return callData; + }, + }; +} + +/** + * Build Multicall3 aggregate3 calls for a batch of balance requests. + * + * @param batch - Balance requests in the current batch. + * @param multicallAddress - Multicall3 contract address for native balance calls. + * @param callDataCache - Per-request cache for encoded balance call data. + * @returns Aggregate3 call descriptors. + */ +function buildAggregate3BalanceCalls( + batch: BalanceOfRequest[], + multicallAddress: Hex, + callDataCache: BalanceCallDataCache, +): { target: Address; allowFailure: boolean; callData: Hex }[] { + return batch.map((req) => { + const isNative = req.tokenAddress === ZERO_ADDRESS; + const target = isNative ? multicallAddress : req.tokenAddress; + return { + target, + allowFailure: true, + callData: isNative + ? callDataCache.getNativeBalanceCallData(req.accountAddress) + : callDataCache.getErc20BalanceCallData(req.accountAddress), + }; + }); +} + /** * Encode a Multicall3 aggregate3 call. * @@ -527,10 +593,11 @@ export class MulticallClient { const multicallAddress = MULTICALL3_ADDRESS_BY_CHAIN[chainId]; const provider = this.#getProvider(chainId); + const callDataCache = createBalanceCallDataCache(); if (!multicallAddress) { return options.fallbackToSingleCalls - ? this.#fallbackBatchBalanceOf(provider, requests) + ? this.#fallbackBatchBalanceOf(provider, requests, callDataCache) : this.#createFailedResponses(requests); } @@ -540,6 +607,7 @@ export class MulticallClient { multicallAddress, requests, options.fallbackToSingleCalls, + callDataCache, ); } @@ -550,6 +618,7 @@ export class MulticallClient { * @param multicallAddress - The Multicall3 contract address. * @param requests - Array of balance requests. * @param fallbackToSingleCalls - Whether to fall back to individual RPC calls on batch failure. + * @param callDataCache - The cache for encoded balance call data. * @returns Array of balance responses. */ async #multicallBatchBalanceOf( @@ -557,6 +626,7 @@ export class MulticallClient { multicallAddress: Hex, requests: BalanceOfRequest[], fallbackToSingleCalls: boolean, + callDataCache: BalanceCallDataCache, ): Promise { const batchSize = this.#config.maxCallsPerBatch; @@ -568,23 +638,16 @@ export class MulticallClient { batchSize, initialResult: [], eachBatch: async (workingResult, batch) => { + const calls = buildAggregate3BalanceCalls( + batch, + multicallAddress, + callDataCache, + ); + try { await createServicePolicy({ maxRetries: MULTICALL_MAX_RETRIES, }).execute(async () => { - // Build aggregate3 calls - const calls = batch.map((req) => { - const isNative = req.tokenAddress === ZERO_ADDRESS; - const target = isNative ? multicallAddress : req.tokenAddress; - return { - target, - allowFailure: true, - callData: isNative - ? encodeGetEthBalance(req.accountAddress) - : encodeBalanceOf(req.accountAddress), - }; - }); - // Encode and send aggregate3 call const callData = encodeAggregate3(calls); const result = await provider.call({ @@ -625,7 +688,9 @@ export class MulticallClient { // #fetchSingleBalance never rejects - it catches all errors internally // and returns a failed response, so we use Promise.all here. const fallbackResults = await Promise.all( - batch.map((req) => this.#fetchSingleBalance(provider, req)), + batch.map((req) => + this.#fetchSingleBalance(provider, req, callDataCache), + ), ); for (const result of fallbackResults) { @@ -649,10 +714,12 @@ export class MulticallClient { * @param provider - The RPC provider. * @param requests - Array of balance requests. * @returns Array of balance responses. + * @param callDataCache - The cache for encoded balance call data. */ async #fallbackBatchBalanceOf( provider: Provider, requests: BalanceOfRequest[], + callDataCache: BalanceCallDataCache, ): Promise { // Use smaller batch size for parallel individual calls to avoid overwhelming RPC const batchSize = Math.min(this.#config.maxCallsPerBatch, 50); @@ -668,7 +735,9 @@ export class MulticallClient { // #fetchSingleBalance never rejects - it catches all errors internally // and returns a failed response, so we use Promise.all here. const batchResults = await Promise.all( - batch.map((req) => this.#fetchSingleBalance(provider, req)), + batch.map((req) => + this.#fetchSingleBalance(provider, req, callDataCache), + ), ); for (const result of batchResults) { @@ -688,10 +757,12 @@ export class MulticallClient { * @param provider - The RPC provider. * @param request - The balance request. * @returns The balance response. + * @param callDataCache - The cache for encoded balance call data. */ async #fetchSingleBalance( provider: Provider, request: BalanceOfRequest, + callDataCache: BalanceCallDataCache, ): Promise { // Destructure inside try block to ensure any errors are caught // and don't cause promise rejections that bypass error handling @@ -710,7 +781,7 @@ export class MulticallClient { } // ERC-20 token - const callData = encodeBalanceOf(accountAddress); + const callData = callDataCache.getErc20BalanceCallData(accountAddress); const result = await provider.call({ to: tokenAddress, data: callData, From 655f6ec739933067a41007e0c7ee5483898fc158 Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 8 Jul 2026 10:42:50 +0200 Subject: [PATCH 2/6] fix: add changelog --- packages/assets-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 55924b951ee..edfbff0f943 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `MulticallClient` memoizes `balanceOf` and `getEthBalance` call encodings per account address when building multicall batches, reducing redundant ABI encoding for wallets with many tokens ([#9423](https://github.com/MetaMask/core/pull/9423)) - Bump `@metamask/transaction-controller` from `^68.2.2` to `^68.3.0` ([#9421](https://github.com/MetaMask/core/pull/9421)) ## [10.1.0] From e6c911be7f1346af0fe55fd6b12b0451d920fd2d Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 8 Jul 2026 14:11:18 +0200 Subject: [PATCH 3/6] fix: add changelog --- packages/assets-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index edfbff0f943..355b120d5c0 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `MulticallClient` memoizes `balanceOf` and `getEthBalance` call encodings per account address when building multicall batches, reducing redundant ABI encoding for wallets with many tokens ([#9423](https://github.com/MetaMask/core/pull/9423)) +- `MulticallClient` memoizes `balanceOf` and `getEthBalance` call encodings per account address when building multicall batches, reducing redundant ABI encoding for wallets with many tokens ([#9425](https://github.com/MetaMask/core/pull/9425)) - Bump `@metamask/transaction-controller` from `^68.2.2` to `^68.3.0` ([#9421](https://github.com/MetaMask/core/pull/9421)) ## [10.1.0] From 1c80599eb1e2b6166af20eb0118485c9df2e2d67 Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 8 Jul 2026 15:18:07 +0200 Subject: [PATCH 4/6] fix: fix PR comments --- .../clients/MulticallClient.test.ts | 37 +++++++++------ .../clients/MulticallClient.ts | 47 ++++++------------- 2 files changed, 38 insertions(+), 46 deletions(-) diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.test.ts index a25bdab107f..cefcd706c69 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.test.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.test.ts @@ -215,14 +215,22 @@ describe('MulticallClient', () => { it('encodes balance call data once per account address in a batch', async () => { const encodeSpy = jest.spyOn(controllerUtils, 'encodeFunctionData'); - const otherAccount: Address = - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Address; + const countBalanceOf = () => + encodeSpy.mock.calls.filter(([, method]) => method === 'balanceOf') + .length; + const countGetEthBalance = () => + encodeSpy.mock.calls.filter(([, method]) => method === 'getEthBalance') + .length; + const accountA: Address = + '0x1111111111111111111111111111111111111111' as Address; + const accountB: Address = + '0x2222222222222222222222222222222222222222' as Address; const requests: BalanceOfRequest[] = [ - { tokenAddress: TEST_TOKEN_1, accountAddress: TEST_ACCOUNT }, - { tokenAddress: TEST_TOKEN_2, accountAddress: TEST_ACCOUNT }, - { tokenAddress: TEST_TOKEN_1, accountAddress: otherAccount }, - { tokenAddress: ZERO_ADDRESS, accountAddress: TEST_ACCOUNT }, + { tokenAddress: TEST_TOKEN_1, accountAddress: accountA }, + { tokenAddress: TEST_TOKEN_2, accountAddress: accountA }, + { tokenAddress: TEST_TOKEN_1, accountAddress: accountB }, + { tokenAddress: ZERO_ADDRESS, accountAddress: accountA }, ]; const mockResponse = buildMockAggregate3Response([ @@ -234,17 +242,18 @@ describe('MulticallClient', () => { mockProvider.call.mockResolvedValue(mockResponse); + const balanceOfBefore = countBalanceOf(); + const getEthBalanceBefore = countGetEthBalance(); + await client.batchBalanceOf(MAINNET_CHAIN_ID, requests); - const balanceOfEncodings = encodeSpy.mock.calls.filter( - ([, method]) => method === 'balanceOf', - ); - const getEthBalanceEncodings = encodeSpy.mock.calls.filter( - ([, method]) => method === 'getEthBalance', - ); + expect(countBalanceOf() - balanceOfBefore).toBe(2); + expect(countGetEthBalance() - getEthBalanceBefore).toBe(1); + + await client.batchBalanceOf(MAINNET_CHAIN_ID, requests); - expect(balanceOfEncodings).toHaveLength(2); - expect(getEthBalanceEncodings).toHaveLength(1); + expect(countBalanceOf() - balanceOfBefore).toBe(2); + expect(countGetEthBalance() - getEthBalanceBefore).toBe(1); encodeSpy.mockRestore(); }); diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.ts index 6056902cfc1..cefcf3f42a4 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.ts @@ -407,13 +407,6 @@ type BalanceCallDataCache = { getNativeBalanceCallData: (accountAddress: Address) => Hex; }; -/** - * Cache balance call encodings per account address for a single batch request. - * ERC-20 `balanceOf` and native `getEthBalance` call data depend only on the - * account being queried, not the token contract (which is the multicall target). - * - * @returns A cache scoped to one `batchBalanceOf` invocation. - */ function createBalanceCallDataCache(): BalanceCallDataCache { const erc20ByAccount = new Map(); const nativeByAccount = new Map(); @@ -442,18 +435,23 @@ function createBalanceCallDataCache(): BalanceCallDataCache { }; } +/** + * Cache balance call encodings per account address. + * ERC-20 `balanceOf` and native `getEthBalance` call data depend only on the + * account being queried, not the token contract (which is the multicall target). + */ +const balanceCallDataCache = createBalanceCallDataCache(); + /** * Build Multicall3 aggregate3 calls for a batch of balance requests. * * @param batch - Balance requests in the current batch. * @param multicallAddress - Multicall3 contract address for native balance calls. - * @param callDataCache - Per-request cache for encoded balance call data. * @returns Aggregate3 call descriptors. */ function buildAggregate3BalanceCalls( batch: BalanceOfRequest[], multicallAddress: Hex, - callDataCache: BalanceCallDataCache, ): { target: Address; allowFailure: boolean; callData: Hex }[] { return batch.map((req) => { const isNative = req.tokenAddress === ZERO_ADDRESS; @@ -462,8 +460,8 @@ function buildAggregate3BalanceCalls( target, allowFailure: true, callData: isNative - ? callDataCache.getNativeBalanceCallData(req.accountAddress) - : callDataCache.getErc20BalanceCallData(req.accountAddress), + ? balanceCallDataCache.getNativeBalanceCallData(req.accountAddress) + : balanceCallDataCache.getErc20BalanceCallData(req.accountAddress), }; }); } @@ -593,11 +591,10 @@ export class MulticallClient { const multicallAddress = MULTICALL3_ADDRESS_BY_CHAIN[chainId]; const provider = this.#getProvider(chainId); - const callDataCache = createBalanceCallDataCache(); if (!multicallAddress) { return options.fallbackToSingleCalls - ? this.#fallbackBatchBalanceOf(provider, requests, callDataCache) + ? this.#fallbackBatchBalanceOf(provider, requests) : this.#createFailedResponses(requests); } @@ -607,7 +604,6 @@ export class MulticallClient { multicallAddress, requests, options.fallbackToSingleCalls, - callDataCache, ); } @@ -618,7 +614,6 @@ export class MulticallClient { * @param multicallAddress - The Multicall3 contract address. * @param requests - Array of balance requests. * @param fallbackToSingleCalls - Whether to fall back to individual RPC calls on batch failure. - * @param callDataCache - The cache for encoded balance call data. * @returns Array of balance responses. */ async #multicallBatchBalanceOf( @@ -626,7 +621,6 @@ export class MulticallClient { multicallAddress: Hex, requests: BalanceOfRequest[], fallbackToSingleCalls: boolean, - callDataCache: BalanceCallDataCache, ): Promise { const batchSize = this.#config.maxCallsPerBatch; @@ -638,11 +632,7 @@ export class MulticallClient { batchSize, initialResult: [], eachBatch: async (workingResult, batch) => { - const calls = buildAggregate3BalanceCalls( - batch, - multicallAddress, - callDataCache, - ); + const calls = buildAggregate3BalanceCalls(batch, multicallAddress); try { await createServicePolicy({ @@ -688,9 +678,7 @@ export class MulticallClient { // #fetchSingleBalance never rejects - it catches all errors internally // and returns a failed response, so we use Promise.all here. const fallbackResults = await Promise.all( - batch.map((req) => - this.#fetchSingleBalance(provider, req, callDataCache), - ), + batch.map((req) => this.#fetchSingleBalance(provider, req)), ); for (const result of fallbackResults) { @@ -714,12 +702,10 @@ export class MulticallClient { * @param provider - The RPC provider. * @param requests - Array of balance requests. * @returns Array of balance responses. - * @param callDataCache - The cache for encoded balance call data. */ async #fallbackBatchBalanceOf( provider: Provider, requests: BalanceOfRequest[], - callDataCache: BalanceCallDataCache, ): Promise { // Use smaller batch size for parallel individual calls to avoid overwhelming RPC const batchSize = Math.min(this.#config.maxCallsPerBatch, 50); @@ -735,9 +721,7 @@ export class MulticallClient { // #fetchSingleBalance never rejects - it catches all errors internally // and returns a failed response, so we use Promise.all here. const batchResults = await Promise.all( - batch.map((req) => - this.#fetchSingleBalance(provider, req, callDataCache), - ), + batch.map((req) => this.#fetchSingleBalance(provider, req)), ); for (const result of batchResults) { @@ -757,12 +741,10 @@ export class MulticallClient { * @param provider - The RPC provider. * @param request - The balance request. * @returns The balance response. - * @param callDataCache - The cache for encoded balance call data. */ async #fetchSingleBalance( provider: Provider, request: BalanceOfRequest, - callDataCache: BalanceCallDataCache, ): Promise { // Destructure inside try block to ensure any errors are caught // and don't cause promise rejections that bypass error handling @@ -781,7 +763,8 @@ export class MulticallClient { } // ERC-20 token - const callData = callDataCache.getErc20BalanceCallData(accountAddress); + const callData = + balanceCallDataCache.getErc20BalanceCallData(accountAddress); const result = await provider.call({ to: tokenAddress, data: callData, From a1d5204847ae562f068c17e93154e927fbf053cf Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 8 Jul 2026 15:27:55 +0200 Subject: [PATCH 5/6] fix: fix linter --- .../evm-rpc-services/clients/MulticallClient.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.test.ts index cefcd706c69..9bde323ec13 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.test.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.test.ts @@ -219,8 +219,9 @@ describe('MulticallClient', () => { encodeSpy.mock.calls.filter(([, method]) => method === 'balanceOf') .length; const countGetEthBalance = () => - encodeSpy.mock.calls.filter(([, method]) => method === 'getEthBalance') - .length; + encodeSpy.mock.calls.filter( + ([, method]) => method === 'getEthBalance', + ).length; const accountA: Address = '0x1111111111111111111111111111111111111111' as Address; const accountB: Address = From fe4349ab3ae7bca1ffac66e285852cbf764e127a Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 8 Jul 2026 15:40:16 +0200 Subject: [PATCH 6/6] fix: fix linter --- .../evm-rpc-services/clients/MulticallClient.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.test.ts index 9bde323ec13..01528dbdee1 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.test.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.test.ts @@ -215,10 +215,10 @@ describe('MulticallClient', () => { it('encodes balance call data once per account address in a batch', async () => { const encodeSpy = jest.spyOn(controllerUtils, 'encodeFunctionData'); - const countBalanceOf = () => + const countBalanceOf = (): number => encodeSpy.mock.calls.filter(([, method]) => method === 'balanceOf') .length; - const countGetEthBalance = () => + const countGetEthBalance = (): number => encodeSpy.mock.calls.filter( ([, method]) => method === 'getEthBalance', ).length;