From 12ea3247568fc561b265e9ee422442ca4e05943c Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Tue, 3 Mar 2026 15:03:48 +0000 Subject: [PATCH 1/3] fix: avoid zeroing balances for excluded accounts fixes https://github.com/MetaMask/metamask-mobile/issues/26707 https://consensyssoftware.atlassian.net/browse/ASSETS-2814 Co-authored-by: Prithpal Sooriya --- .../api-balance-fetcher.test.ts | 130 +++++++++++++++++- .../api-balance-fetcher.ts | 12 +- 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts index 118868d61ce..0703a3cbca3 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts @@ -803,6 +803,135 @@ describe('AccountsApiBalanceFetcher', () => { expect(result.balances[1].value).toStrictEqual(new BN('0')); // balance is zero now since API did not return a value for this token }); + it('should not zero out erc20 balances for accounts excluded from selected-account requests', async () => { + const selectedAccountToken = + '0x0xaf88d065e77c8cC2239327C5EDb3A432268e5831'; + const excludedAccountToken = '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E'; + + mockFetchMultiChainBalancesV4.mockResolvedValue({ + count: 1, + balances: [createMockNativeTokenBalance({ chainId: 1 })], + unprocessedNetworks: [], + }); + + balanceFetcher = new AccountsApiBalanceFetcher( + 'extension', + undefined, + () => ({ + [MOCK_ADDRESS_1]: { + '0x1': { + [ZERO_ADDRESS]: {}, + [selectedAccountToken]: '0x814a20', + }, + }, + [MOCK_ADDRESS_2]: { + '0x1': { + [ZERO_ADDRESS]: {}, + [excludedAccountToken]: '0x814a20', + }, + }, + }), + ); + + const result = await balanceFetcher.fetch({ + chainIds: ['0x1'], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + const zeroedSelectedAccountToken = result.balances.find( + (balance) => + balance.account === MOCK_ADDRESS_1 && + balance.token === selectedAccountToken.toLowerCase(), + ); + expect(zeroedSelectedAccountToken).toStrictEqual( + expect.objectContaining({ + success: true, + value: new BN('0'), + account: MOCK_ADDRESS_1, + token: selectedAccountToken.toLowerCase(), + chainId: '0x1', + }), + ); + + const zeroedExcludedAccountToken = result.balances.find( + (balance) => + balance.account === MOCK_ADDRESS_2 && + balance.token === excludedAccountToken.toLowerCase(), + ); + expect(zeroedExcludedAccountToken).toBeUndefined(); + }); + + it('should not zero out erc20 balances for accounts excluded from all-accounts requests', async () => { + const includedAccountToken = + '0x0xaf88d065e77c8cC2239327C5EDb3A432268e5831'; + const excludedAccount = '0x1111111111111111111111111111111111111111'; + const excludedAccountToken = '0xA0b86a33E6441c86c33E1C6B9cD964c0BA2A86B'; + + mockFetchMultiChainBalancesV4.mockResolvedValue({ + count: 2, + balances: [ + createMockNativeTokenBalance({ + accountAddress: `eip155:1:${MOCK_ADDRESS_1}`, + }), + createMockNativeTokenBalance({ + balance: '2.0', + accountAddress: `eip155:1:${MOCK_ADDRESS_2}`, + }), + ], + unprocessedNetworks: [], + }); + + balanceFetcher = new AccountsApiBalanceFetcher( + 'extension', + undefined, + () => ({ + [MOCK_ADDRESS_1]: { + '0x1': { + [ZERO_ADDRESS]: {}, + [includedAccountToken]: '0x814a20', + }, + }, + [excludedAccount]: { + '0x1': { + [ZERO_ADDRESS]: {}, + [excludedAccountToken]: '0x814a20', + }, + }, + }), + ); + + const result = await balanceFetcher.fetch({ + chainIds: ['0x1'], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + const zeroedIncludedAccountToken = result.balances.find( + (balance) => + balance.account === MOCK_ADDRESS_1 && + balance.token === includedAccountToken.toLowerCase(), + ); + expect(zeroedIncludedAccountToken).toStrictEqual( + expect.objectContaining({ + success: true, + value: new BN('0'), + account: MOCK_ADDRESS_1, + token: includedAccountToken.toLowerCase(), + chainId: '0x1', + }), + ); + + const zeroedExcludedAccountToken = result.balances.find( + (balance) => + balance.account === excludedAccount && + balance.token === excludedAccountToken.toLowerCase(), + ); + expect(zeroedExcludedAccountToken).toBeUndefined(); + }); + it('should not include erc20 token entry for chains that are not supported by account API', async () => { balanceFetcher = arrangeBalanceFetcher(); @@ -830,7 +959,6 @@ describe('AccountsApiBalanceFetcher', () => { allAccounts: MOCK_INTERNAL_ACCOUNTS, }); - console.log(result.balances); expect(result.balances).toHaveLength(1); expect(result.balances[0]).toStrictEqual( expect.objectContaining({ diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts index 23ae9a6348f..df1ffd39bb3 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts @@ -442,8 +442,18 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { const existingBalance = nonNativeBalancesFromAPI.get(key); const isChainIncludedInRequest = chainIds.includes(chainId as Hex); const isChainSupported = this.supports(chainId as Hex); + const isAccountIncludedInRequest = queryAllAccounts + ? allAccounts.some( + (currentAccount) => + currentAccount.address.toLowerCase() === + account.toLowerCase(), + ) + : selectedAccount.toLowerCase() === account.toLowerCase(); const shouldZeroOutBalance = - !existingBalance && isChainIncludedInRequest && isChainSupported; + !existingBalance && + isChainIncludedInRequest && + isChainSupported && + isAccountIncludedInRequest; if (isERC && shouldZeroOutBalance) { results.push({ From 45b6875bdfc20ea7ad4458efc53b09f1e5ae59ca Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Tue, 3 Mar 2026 15:14:59 +0000 Subject: [PATCH 2/3] chore: add changelog entry for account zeroing fix Co-authored-by: Prithpal Sooriya --- packages/assets-controllers/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 98d287318a4..b6732fd8fbd 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix `AccountsApiBalanceFetcher` to apply stricter conditions when zeroing out token balances ([#8044](https://github.com/MetaMask/core/pull/8044)) +- Fix `AccountsApiBalanceFetcher` ERC-20 zeroing to only apply to accounts included in the current request, preventing stale-account entries from being incorrectly reset to zero ([#8095](https://github.com/MetaMask/core/pull/8095)) ## [100.0.3] From b9ecfef360450ec0f51da962a54e965bb0f3452b Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Tue, 3 Mar 2026 15:43:52 +0000 Subject: [PATCH 3/3] refactor: share account inclusion guard for zeroing Co-authored-by: Prithpal Sooriya --- .../api-balance-fetcher.test.ts | 44 +++++++++++++++++++ .../api-balance-fetcher.ts | 24 ++++++---- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts index 0703a3cbca3..f8f948590d6 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts @@ -756,6 +756,50 @@ describe('AccountsApiBalanceFetcher', () => { expect(nativeAddr1?.value).toStrictEqual(new BN('1500000000000000000')); // 1.5 ETH expect(nativeAddr2?.value).toStrictEqual(new BN('0')); // Zero balance (not returned by API) }); + + it('should not zero out native balances for addresses excluded from selected-account requests', async () => { + const excludedAddress = '0x1111111111111111111111111111111111111111'; + + mockAccountAddressToCaipReference.mockReturnValue( + `eip155:1:${excludedAddress}`, + ); + mockFetchMultiChainBalancesV4.mockResolvedValue({ + count: 0, + balances: [], + unprocessedNetworks: [], + }); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(result.balances).toStrictEqual([]); + }); + + it('should not zero out native balances for addresses excluded from all-accounts requests', async () => { + const excludedAddress = '0x1111111111111111111111111111111111111111'; + + mockAccountAddressToCaipReference.mockReturnValue( + `eip155:1:${excludedAddress}`, + ); + mockFetchMultiChainBalancesV4.mockResolvedValue({ + count: 0, + balances: [], + unprocessedNetworks: [], + }); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(result.balances).toStrictEqual([]); + }); }); describe('erc20 token zero balance guarantee', () => { diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts index df1ffd39bb3..bd0386960cb 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts @@ -407,6 +407,14 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { results.push(...apiBalances); } + const isAccountIncludedInRequest = (address: string): boolean => + queryAllAccounts + ? allAccounts.some( + (currentAccount) => + currentAccount.address.toLowerCase() === address.toLowerCase(), + ) + : selectedAccount.toLowerCase() === address.toLowerCase(); + // Add zero native balance entries for addresses that API didn't return addressChainMap.forEach((chains, address) => { chains.forEach((chainId) => { @@ -414,8 +422,12 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { const existingBalance = nativeBalancesFromAPI.get(key); const isChainIncludedInRequest = chainIds.includes(chainId); const isChainSupported = this.supports(chainId); + const isAccountIncluded = isAccountIncludedInRequest(address); const shouldZeroOutBalance = - !existingBalance && isChainIncludedInRequest && isChainSupported; + !existingBalance && + isChainIncludedInRequest && + isChainSupported && + isAccountIncluded; if (shouldZeroOutBalance) { // Add zero native balance entry if API succeeded but didn't return one @@ -442,18 +454,12 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { const existingBalance = nonNativeBalancesFromAPI.get(key); const isChainIncludedInRequest = chainIds.includes(chainId as Hex); const isChainSupported = this.supports(chainId as Hex); - const isAccountIncludedInRequest = queryAllAccounts - ? allAccounts.some( - (currentAccount) => - currentAccount.address.toLowerCase() === - account.toLowerCase(), - ) - : selectedAccount.toLowerCase() === account.toLowerCase(); + const isAccountIncluded = isAccountIncludedInRequest(account); const shouldZeroOutBalance = !existingBalance && isChainIncludedInRequest && isChainSupported && - isAccountIncludedInRequest; + isAccountIncluded; if (isERC && shouldZeroOutBalance) { results.push({