diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index d5c1557f610..cfdab5e02d7 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Add 30-second timeout protection for Accounts API calls in `TokenDetectionController` to prevent hanging requests ([#7106](https://github.com/MetaMask/core/pull/7106)) + - Prevents token detection from hanging indefinitely on slow or unresponsive API requests + - Automatically falls back to RPC-based token detection when API call times out or fails + - Includes error logging for debugging timeout and failure events +- Handle `unprocessedNetworks` from Accounts API responses to ensure complete token detection coverage ([#7106](https://github.com/MetaMask/core/pull/7106)) + - When Accounts API returns networks it cannot process, those networks are automatically added to RPC detection + - Applies to both `TokenDetectionController` and `TokenBalancesController` + - Ensures all requested networks are processed even if API has partial support + ## [88.0.0] ### Changed diff --git a/packages/assets-controllers/src/AccountTrackerController.test.ts b/packages/assets-controllers/src/AccountTrackerController.test.ts index dd660819432..a3a467c1cca 100644 --- a/packages/assets-controllers/src/AccountTrackerController.test.ts +++ b/packages/assets-controllers/src/AccountTrackerController.test.ts @@ -594,6 +594,57 @@ describe('AccountTrackerController', () => { }, ); }); + + it('should create account entry when applying staked balance without native balance (line 743)', async () => { + // Mock returning staked balance for ADDRESS_1 and native balance for ADDRESS_2 + // but NO native balance for ADDRESS_1 - this tests the defensive check on line 743 + // Use lowercase addresses since queryAllAccounts: true uses lowercase + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + // Only ADDRESS_2 has native balance, ADDRESS_1 doesn't + [ADDRESS_2]: new BN('100', 16), + }, + }, + stakedBalances: { + // ADDRESS_1 has staked balance but no native balance + [ADDRESS_1]: new BN('2', 16), // 0x2 + [ADDRESS_2]: new BN('3', 16), // 0x3 + }, + }); + + await withController( + { + options: { + includeStakedAssets: true, + getStakedBalanceForChain: mockGetStakedBalanceForChain, + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ controller, refresh }) => { + await refresh(clock, ['mainnet'], true); + + // Line 743 should have created an account entry with balance '0x0' for ADDRESS_1 + // when applying staked balance without a native balance entry + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x0', // Created by line 743 (defensive check) + stakedBalance: '0x2', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x100', + stakedBalance: '0x3', + }, + }, + }, + }); + }, + ); + }); }); describe('with networkClientId', () => { diff --git a/packages/assets-controllers/src/AccountTrackerController.ts b/packages/assets-controllers/src/AccountTrackerController.ts index 527d99cccde..c61a6118567 100644 --- a/packages/assets-controllers/src/AccountTrackerController.ts +++ b/packages/assets-controllers/src/AccountTrackerController.ts @@ -91,14 +91,19 @@ function createAccountTrackerRpcBalanceFetcher( }, async fetch(params) { - const balances = await rpcBalanceFetcher.fetch(params); + const result = await rpcBalanceFetcher.fetch(params); if (!includeStakedAssets) { // Filter out staked balances from the results - return balances.filter((balance) => balance.token === ZERO_ADDRESS); + return { + balances: result.balances.filter( + (balance) => balance.token === ZERO_ADDRESS, + ), + unprocessedChainIds: result.unprocessedChainIds, + }; } - return balances; + return result; }, }; } @@ -630,21 +635,38 @@ export class AccountTrackerController extends StaticIntervalPollingController 0) { - aggregated.push(...balances); + if (result.balances && result.balances.length > 0) { + aggregated.push(...result.balances); // Remove chains that were successfully processed - const processedChains = new Set(balances.map((b) => b.chainId)); + const processedChains = new Set( + result.balances.map((b) => b.chainId), + ); remainingChains = remainingChains.filter( (chain) => !processedChains.has(chain), ); } + + // Add unprocessed chains back to remainingChains for next fetcher + if ( + result.unprocessedChainIds && + result.unprocessedChainIds.length > 0 + ) { + // Only add chains that were originally requested and aren't already in remainingChains + const currentRemainingChains = remainingChains; + const chainsToAdd = result.unprocessedChainIds.filter( + (chainId) => + supportedChains.includes(chainId) && + !currentRemainingChains.includes(chainId), + ); + remainingChains.push(...chainsToAdd); + } } catch (error) { console.warn( `Balance fetcher failed for chains ${supportedChains.join(', ')}: ${String(error)}`, diff --git a/packages/assets-controllers/src/CurrencyRateController.test.ts b/packages/assets-controllers/src/CurrencyRateController.test.ts index ddec0853ae6..7f520926af2 100644 --- a/packages/assets-controllers/src/CurrencyRateController.test.ts +++ b/packages/assets-controllers/src/CurrencyRateController.test.ts @@ -885,6 +885,100 @@ describe('CurrencyRateController', () => { controller.destroy(); }); + it('should set conversionDate to null when currency not found in price api response (lines 201-202)', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessenger(); + + const tokenPricesService = buildMockTokenPricesService(); + + // Mock price API response where BNB is not included + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 1000, + usd: 1 / 3000, + currencyType: 'crypto', + }, + // BNB is missing from the response + }); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'xyz' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH', 'BNB']); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'xyz', + currencyRates: { + ETH: { + conversionDate, + conversionRate: 1000, + usdConversionRate: 3000, + }, + BNB: { + conversionDate: null, // Line 201: rate === undefined + conversionRate: null, // Line 202 + usdConversionRate: null, + }, + }, + }); + + controller.destroy(); + }); + + it('should set conversionDate to null when currency not found in crypto compare response (lines 231-232)', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + const cryptoCompareHost = 'https://min-api.cryptocompare.com'; + nock(cryptoCompareHost) + .get('/data/pricemulti?fsyms=ETH,BNB&tsyms=xyz') + .reply(200, { + ETH: { XYZ: 4000.42 }, + // BNB is missing from the response + }) + .persist(); + + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + + // Make price API fail so it falls back to CryptoCompare + jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockRejectedValue(new Error('Failed to fetch')); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'xyz' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH', 'BNB']); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'xyz', + currencyRates: { + ETH: { + conversionDate, + conversionRate: 4000.42, + usdConversionRate: null, + }, + BNB: { + conversionDate: null, // Line 231: rate === undefined + conversionRate: null, // Line 232 + usdConversionRate: null, + }, + }, + }); + + controller.destroy(); + }); + describe('useExternalServices', () => { it('should not fetch exchange rates when useExternalServices is false', async () => { const fetchMultiExchangeRateStub = jest.fn(); diff --git a/packages/assets-controllers/src/TokenBalancesController.test.ts b/packages/assets-controllers/src/TokenBalancesController.test.ts index 2f4232b827b..085b356ae43 100644 --- a/packages/assets-controllers/src/TokenBalancesController.test.ts +++ b/packages/assets-controllers/src/TokenBalancesController.test.ts @@ -1459,7 +1459,7 @@ describe('TokenBalancesController', () => { // Mock the RPC balance fetcher's fetch method to verify the parameter const mockRpcFetch = jest.spyOn(RpcBalanceFetcher.prototype, 'fetch'); - mockRpcFetch.mockResolvedValueOnce([]); + mockRpcFetch.mockResolvedValueOnce({ balances: [] }); const { controller } = setupController({ config: { @@ -1771,7 +1771,7 @@ describe('TokenBalancesController', () => { // Mock empty aggregated results const mockFetcher = { supports: jest.fn().mockReturnValue(true), - fetch: jest.fn().mockResolvedValue([]), // Return empty array + fetch: jest.fn().mockResolvedValue({ balances: [] }), // Return empty result }; // Replace the balance fetchers with our mock @@ -4250,7 +4250,7 @@ describe('TokenBalancesController', () => { // Mock AccountsApiBalanceFetcher to track when line 320 logic is executed const mockSupports = jest.fn().mockReturnValue(true); - const mockApiFetch = jest.fn().mockResolvedValue([]); + const mockApiFetch = jest.fn().mockResolvedValue({ balances: [] }); const apiBalanceFetcher = jest.requireActual( './multi-chain-accounts-service/api-balance-fetcher', diff --git a/packages/assets-controllers/src/TokenBalancesController.ts b/packages/assets-controllers/src/TokenBalancesController.ts index 30d3db908f5..f8bfed95b13 100644 --- a/packages/assets-controllers/src/TokenBalancesController.ts +++ b/packages/assets-controllers/src/TokenBalancesController.ts @@ -653,21 +653,37 @@ export class TokenBalancesController extends StaticIntervalPollingController<{ } try { - const balances = await fetcher.fetch({ + const result = await fetcher.fetch({ chainIds: supportedChains, queryAllAccounts: queryAllAccounts ?? this.#queryAllAccounts, selectedAccount: selected as ChecksumAddress, allAccounts, }); - if (balances && balances.length > 0) { - aggregated.push(...balances); + if (result.balances && result.balances.length > 0) { + aggregated.push(...result.balances); // Remove chains that were successfully processed - const processedChains = new Set(balances.map((b) => b.chainId)); + const processedChains = new Set( + result.balances.map((b) => b.chainId), + ); remainingChains = remainingChains.filter( (chain) => !processedChains.has(chain), ); } + + // Add unprocessed chains back to remainingChains for next fetcher + if ( + result.unprocessedChainIds && + result.unprocessedChainIds.length > 0 + ) { + const currentRemainingChains = remainingChains; + const chainsToAdd = result.unprocessedChainIds.filter( + (chainId) => + supportedChains.includes(chainId) && + !currentRemainingChains.includes(chainId), + ); + remainingChains.push(...chainsToAdd); + } } catch (error) { console.warn( `Balance fetcher failed for chains ${supportedChains.join(', ')}: ${String(error)}`, diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 4ea7ed05065..15d8d6f59cc 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -2765,6 +2765,172 @@ describe('TokenDetectionController', () => { ); }); + it('should timeout and fallback to RPC when Accounts API call takes longer than 30 seconds', async () => { + // Use fake timers to simulate the 30-second timeout + const clock = sinon.useFakeTimers(); + + try { + // Arrange - RPC Tokens Flow - Uses sampleTokenA + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + + // Mock a hanging API call that never resolves (simulates network timeout) + const mockAPI = mockMultiChainAccountsService(); + mockAPI.mockFetchSupportedNetworks.mockResolvedValue([1]); + mockAPI.mockFetchMultiChainBalances.mockImplementation( + () => + new Promise(() => { + // Promise that never resolves (simulating a hanging request) + }), + ); + + // Arrange - Selected Account + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + // Arrange / Act - withController setup + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + useAccountsAPI: true, // USING ACCOUNTS API + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ controller, mockTokenListGetState, callActionSpy }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0x1': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + // Start the detection process (don't await yet so we can advance time) + const detectPromise = controller.detectTokens({ + chainIds: ['0x1'], + selectedAddress: selectedAccount.address, + }); + + // Fast-forward time by 30 seconds to trigger the timeout + // This simulates the API call taking longer than the ACCOUNTS_API_TIMEOUT_MS (30000ms) + await advanceTime({ clock, duration: 30000 }); + + // Now await the result after the timeout has been triggered + await detectPromise; + + // Verify that the API was initially called + expect(mockAPI.mockFetchMultiChainBalances).toHaveBeenCalled(); + + // Verify that after timeout, RPC fallback was triggered + expect(mockGetBalancesInSingleCall).toHaveBeenCalled(); + + // Verify that tokens were added via RPC fallback method + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [sampleTokenA], + 'mainnet', + ); + }, + ); + } finally { + clock.restore(); + } + }); + + it('should fallback to RPC when Accounts API call fails with an error (safelyExecute returns undefined)', async () => { + // Arrange - RPC Tokens Flow - Uses sampleTokenA + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + + // Mock an API call that throws an error inside safelyExecute + // This simulates a scenario where the API throws an error (network failure, parsing error, etc.) + const mockAPI = mockMultiChainAccountsService(); + mockAPI.mockFetchSupportedNetworks.mockResolvedValue([1]); + mockAPI.mockFetchMultiChainBalances.mockRejectedValue( + new Error('API Network Error'), + ); + + // Arrange - Selected Account + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + // Arrange / Act - withController setup + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + useAccountsAPI: true, // USING ACCOUNTS API + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ controller, mockTokenListGetState, callActionSpy }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0x1': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + // Execute detection + await controller.detectTokens({ + chainIds: ['0x1'], + selectedAddress: selectedAccount.address, + }); + + // Verify that the API was initially called + expect(mockAPI.mockFetchMultiChainBalances).toHaveBeenCalled(); + + // Verify that after API error (safelyExecute returns undefined), RPC fallback was triggered + expect(mockGetBalancesInSingleCall).toHaveBeenCalled(); + + // Verify that tokens were added via RPC fallback method + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [sampleTokenA], + 'mainnet', + ); + }, + ); + }); + /** * Test Utility - Arrange and Act `detectTokens()` with the Accounts API feature * RPC flow will return `sampleTokenA` and the Accounts API flow will use `sampleTokenB` diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 339d5e99a01..cd3193d5d77 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -13,8 +13,10 @@ import { ChainId, ERC20, safelyExecute, + safelyExecuteWithTimeout, isEqualCaseInsensitive, toChecksumHexAddress, + toHex, } from '@metamask/controller-utils'; import type { KeyringControllerGetStateAction, @@ -60,6 +62,7 @@ import type { } from './TokensController'; const DEFAULT_INTERVAL = 180000; +const ACCOUNTS_API_TIMEOUT_MS = 30000; type LegacyToken = { name: string; @@ -265,7 +268,8 @@ export class TokenDetectionController extends StaticIntervalPollingController { + return this.#addDetectedTokensViaAPI({ + chainIds: chainsToDetectUsingAccountAPI, + selectedAddress: addressToDetect, + supportedNetworks, + }); + }, + false, + ACCOUNTS_API_TIMEOUT_MS, + ); + + if (!result) { + return { result: 'failed' } as const; + } + + return result; } #addChainsToRpcDetection( @@ -720,13 +736,27 @@ export class TokenDetectionController extends StaticIntervalPollingController 0 + ) { + // Handle unprocessed networks by adding them to RPC detection + const unprocessedChainIds = apiResult.unprocessedNetworks.map( + (chainId: number) => toHex(chainId), + ) as Hex[]; + this.#addChainsToRpcDetection( + chainsToDetectUsingRpc, + unprocessedChainIds, + clientNetworks, + ); } } @@ -826,14 +856,16 @@ export class TokenDetectionController extends StaticIntervalPollingController { // Fetch balances for multiple chain IDs at once - const tokenBalancesByChain = await this.#accountsAPI + const apiResponse = await this.#accountsAPI .getMultiNetworksBalances(selectedAddress, chainIds, supportedNetworks) .catch(() => null); - if (tokenBalancesByChain === null) { + if (apiResponse === null) { return { result: 'failed' } as const; } + const tokenBalancesByChain = apiResponse.balances; + // Process each chain ID individually for (const chainId of chainIds) { const isTokenDetectionInactiveInMainnet = @@ -893,7 +925,10 @@ export class TokenDetectionController extends StaticIntervalPollingController { allAccounts: MOCK_INTERNAL_ACCOUNTS, }); - expect(result).toStrictEqual([]); + expect(result).toStrictEqual({ balances: [] }); expect(mockFetchMultiChainBalancesV4).not.toHaveBeenCalled(); }); @@ -306,7 +306,7 @@ describe('AccountsApiBalanceFetcher', () => { allAccounts: MOCK_INTERNAL_ACCOUNTS, }); - expect(result).toStrictEqual([]); + expect(result).toStrictEqual({ balances: [] }); expect(mockFetchMultiChainBalancesV4).not.toHaveBeenCalled(); }); @@ -360,15 +360,15 @@ describe('AccountsApiBalanceFetcher', () => { 'extension', ); - expect(result).toHaveLength(2); - expect(result[0]).toStrictEqual({ + expect(result.balances).toHaveLength(2); + expect(result.balances[0]).toStrictEqual({ success: true, value: new BN('1500000000000000000'), account: MOCK_ADDRESS_1, token: '0x0000000000000000000000000000000000000000', chainId: '0x1', }); - expect(result[1]).toStrictEqual({ + expect(result.balances[1]).toStrictEqual({ success: true, value: new BN('100000000000000000000'), account: MOCK_ADDRESS_1, @@ -397,7 +397,45 @@ describe('AccountsApiBalanceFetcher', () => { 'extension', ); - expect(result).toHaveLength(3); + expect(result.balances).toHaveLength(3); + }); + + it('should convert unprocessedNetworks from decimal to hex chain IDs (line 294)', async () => { + const responseWithUnprocessed = { + count: 1, + balances: [ + { + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + chainId: 1, + balance: '1.0', + accountAddress: + 'eip155:1:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + }, + ], + unprocessedNetworks: [137, 42161, 10, 8453], // Polygon, Arbitrum, Optimism, Base (in decimal) + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue(responseWithUnprocessed); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Verify conversion from decimal to hex (line 294) + expect(result.unprocessedChainIds).toBeDefined(); + expect(result.unprocessedChainIds).toStrictEqual([ + '0x89', // 137 -> 0x89 (Polygon) + '0xa4b1', // 42161 -> 0xa4b1 (Arbitrum) + '0xa', // 10 -> 0xa (Optimism) + '0x2105', // 8453 -> 0x2105 (Base) + ]); }); it('should handle large batch requests using reduceInBatchesSerially', async () => { @@ -468,7 +506,87 @@ describe('AccountsApiBalanceFetcher', () => { expect(mockFetchMultiChainBalancesV4).toHaveBeenCalledTimes(2); // Should have more results due to native token guarantees for all 60 accounts - expect(result.length).toBeGreaterThan(3); + expect(result.balances.length).toBeGreaterThan(3); + }); + + it('should collect unprocessedNetworks from multiple batches (line 241)', async () => { + // Create a large number of CAIP addresses to exceed ACCOUNTS_API_BATCH_SIZE (50) + const largeAccountList: InternalAccount[] = []; + const caipAddresses: string[] = []; + + for (let i = 0; i < 60; i++) { + const address = + `0x${'0'.repeat(39)}${i.toString().padStart(1, '0')}` as ChecksumAddress; + largeAccountList.push({ + id: i.toString(), + address, + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: [], + metadata: { + name: `Account ${i}`, + importTime: Date.now(), + keyring: { type: 'HD Key Tree' }, + }, + }); + caipAddresses.push(`eip155:1:${address}`); + } + + // Mock reduceInBatchesSerially to simulate batching behavior + mockReduceInBatchesSerially.mockImplementation( + async ({ + eachBatch, + initialResult, + }: { + eachBatch: ( + result: unknown, + batch: unknown, + index: number, + ) => Promise; + initialResult: unknown; + }) => { + const batch1 = caipAddresses.slice(0, 50); + const batch2 = caipAddresses.slice(50); + + let result = initialResult; + result = await eachBatch(result, batch1, 0); + result = await eachBatch(result, batch2, 1); + + return result; + }, + ); + + // Mock the API to return different unprocessedNetworks for each batch + mockFetchMultiChainBalancesV4 + .mockResolvedValueOnce({ + count: 0, + balances: [], + unprocessedNetworks: [137, 42161], // Batch 1: Polygon and Arbitrum + }) + .mockResolvedValueOnce({ + count: 0, + balances: [], + unprocessedNetworks: [10, 137], // Batch 2: Optimism and Polygon (duplicate) + }); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: largeAccountList, + }); + + // Should have been called twice (2 batches) + expect(mockFetchMultiChainBalancesV4).toHaveBeenCalledTimes(2); + + // Line 241 should have collected all unique networks from both batches + // The Set deduplicates 137 (Polygon) which appears in both batches + expect(result.unprocessedChainIds).toBeDefined(); + expect(result.unprocessedChainIds).toStrictEqual( + expect.arrayContaining(['0x89', '0xa4b1', '0xa']), + ); + expect(result.unprocessedChainIds).toHaveLength(3); // No duplicates }); it('should handle missing account address in response', async () => { @@ -501,10 +619,10 @@ describe('AccountsApiBalanceFetcher', () => { }); // Should have native token guarantee even with missing account address - expect(result).toHaveLength(1); - expect(result[0].token).toBe(ZERO_ADDRESS); - expect(result[0].success).toBe(true); - expect(result[0].value).toStrictEqual(new BN('0')); + expect(result.balances).toHaveLength(1); + expect(result.balances[0].token).toBe(ZERO_ADDRESS); + expect(result.balances[0].success).toBe(true); + expect(result.balances[0].value).toStrictEqual(new BN('0')); }); it('should correctly convert balance values with different decimals', async () => { @@ -548,12 +666,12 @@ describe('AccountsApiBalanceFetcher', () => { allAccounts: MOCK_INTERNAL_ACCOUNTS, }); - expect(result).toHaveLength(3); // 2 tokens + native token guarantee + expect(result.balances).toHaveLength(3); // 2 tokens + native token guarantee // DAI with 18 decimals: 123.456789 -> using string-based conversion // Convert received hex value to decimal to get the correct expected value const expectedDaiValue = new BN('6b14e9f7e4f5a5000', 16); - expect(result[0]).toStrictEqual({ + expect(result.balances[0]).toStrictEqual({ success: true, value: expectedDaiValue, account: MOCK_ADDRESS_1, @@ -562,7 +680,7 @@ describe('AccountsApiBalanceFetcher', () => { }); // USDC with 6 decimals: 100.5 * 10^6 - expect(result[1]).toStrictEqual({ + expect(result.balances[1]).toStrictEqual({ success: true, value: new BN('100500000'), account: MOCK_ADDRESS_1, @@ -657,17 +775,19 @@ describe('AccountsApiBalanceFetcher', () => { allAccounts: MOCK_INTERNAL_ACCOUNTS, }); - expect(result).toHaveLength(2); // DAI token + native token (zero balance) + expect(result.balances).toHaveLength(2); // DAI token + native token (zero balance) // Should include the DAI token - const daiBalance = result.find( + const daiBalance = result.balances.find( (r) => r.token === '0x6B175474E89094C44Da98b954EedeAC495271d0F', ); expect(daiBalance).toBeDefined(); expect(daiBalance?.success).toBe(true); // Should include native token with zero balance - const nativeBalance = result.find((r) => r.token === ZERO_ADDRESS); + const nativeBalance = result.balances.find( + (r) => r.token === ZERO_ADDRESS, + ); expect(nativeBalance).toBeDefined(); expect(nativeBalance?.success).toBe(true); expect(nativeBalance?.value).toStrictEqual(new BN('0')); @@ -718,10 +838,12 @@ describe('AccountsApiBalanceFetcher', () => { }); // Should have 4 entries: ETH for addr1, DAI for addr2, and native (0) for addr2 - expect(result).toHaveLength(3); + expect(result.balances).toHaveLength(3); // Verify native balances for both addresses - const nativeBalances = result.filter((r) => r.token === ZERO_ADDRESS); + const nativeBalances = result.balances.filter( + (r) => r.token === ZERO_ADDRESS, + ); expect(nativeBalances).toHaveLength(2); const nativeAddr1 = nativeBalances.find( @@ -789,10 +911,10 @@ describe('AccountsApiBalanceFetcher', () => { }); // Should include API balances + staked balance - expect(result.length).toBeGreaterThan(3); // Original 3 + staked balances + expect(result.balances.length).toBeGreaterThan(3); // Original 3 + staked balances // Check for staked balance - const stakedBalance = result.find( + const stakedBalance = result.balances.find( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); expect(stakedBalance).toBeDefined(); @@ -818,7 +940,7 @@ describe('AccountsApiBalanceFetcher', () => { }); // Should include staked balance entry with zero value when shares are zero - const stakedBalance = result.find( + const stakedBalance = result.balances.find( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); expect(stakedBalance).toBeDefined(); @@ -842,8 +964,8 @@ describe('AccountsApiBalanceFetcher', () => { }); // Should still return API balances + native token guarantee, but failed staked balance - expect(result.length).toBeGreaterThan(2); // API results + native token + failed staking - const stakedBalance = result.find( + expect(result.balances.length).toBeGreaterThan(2); // API results + native token + failed staking + const stakedBalance = result.balances.find( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); expect(stakedBalance).toBeDefined(); @@ -882,7 +1004,7 @@ describe('AccountsApiBalanceFetcher', () => { expect(mockProvider.call).not.toHaveBeenCalled(); // Should not include staked balance - const stakedBalance = result.find( + const stakedBalance = result.balances.find( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); expect(stakedBalance).toBeUndefined(); @@ -920,14 +1042,16 @@ describe('AccountsApiBalanceFetcher', () => { }); // Should include native token but no staked balance for Polygon - expect(result.length).toBeGreaterThan(0); - const stakedBalance = result.find( + expect(result.balances.length).toBeGreaterThan(0); + const stakedBalance = result.balances.find( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); expect(stakedBalance).toBeUndefined(); // No staked balance for unsupported staking chain // Should have native token balance - const nativeBalance = result.find((r) => r.token === ZERO_ADDRESS); + const nativeBalance = result.balances.find( + (r) => r.token === ZERO_ADDRESS, + ); expect(nativeBalance).toBeDefined(); }); @@ -991,14 +1115,16 @@ describe('AccountsApiBalanceFetcher', () => { }); // Should include native token but no staked balance due to missing contract address - expect(result.length).toBeGreaterThan(0); - const stakedBalance = result.find( + expect(result.balances.length).toBeGreaterThan(0); + const stakedBalance = result.balances.find( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); expect(stakedBalance).toBeUndefined(); // No staked balance due to missing contract address // Should have native token balance - const nativeBalance = result.find((r) => r.token === ZERO_ADDRESS); + const nativeBalance = result.balances.find( + (r) => r.token === ZERO_ADDRESS, + ); expect(nativeBalance).toBeDefined(); } finally { // Restore original mocks @@ -1048,7 +1174,7 @@ describe('AccountsApiBalanceFetcher', () => { }); // Should still return API balances and native token guarantee, but no staked balances - expect(result.length).toBeGreaterThan(0); + expect(result.balances.length).toBeGreaterThan(0); // Verify console.error was called with contract setup error expect(consoleSpy).toHaveBeenCalledWith( @@ -1059,7 +1185,7 @@ describe('AccountsApiBalanceFetcher', () => { ); // Should not have any staked balance due to contract setup failure - const stakedBalance = result.find( + const stakedBalance = result.balances.find( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); expect(stakedBalance).toBeUndefined(); @@ -1084,8 +1210,8 @@ describe('AccountsApiBalanceFetcher', () => { }); // Should return API balances plus native token guarantee (but no staked balances) - expect(result).toHaveLength(3); // Original API results + native token - const stakedBalance = result.find( + expect(result.balances).toHaveLength(3); // Original API results + native token + const stakedBalance = result.balances.find( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); expect(stakedBalance).toBeUndefined(); @@ -1135,7 +1261,7 @@ describe('AccountsApiBalanceFetcher', () => { MOCK_CHAIN_ID, MOCK_ADDRESS_1, ); - expect(result.length).toBeGreaterThan(0); + expect(result.balances.length).toBeGreaterThan(0); }); it('should handle balance parsing errors gracefully (covers try-catch in line 298)', async () => { @@ -1167,9 +1293,9 @@ describe('AccountsApiBalanceFetcher', () => { }); // Should have native token (guaranteed) and failed balance - expect(result).toHaveLength(2); + expect(result.balances).toHaveLength(2); - const failedBalance = result.find( + const failedBalance = result.balances.find( (r) => r.token === '0x6B175474E89094C44Da98b954EedeAC495271d0F', ); expect(failedBalance?.success).toBe(false); @@ -1216,7 +1342,7 @@ describe('AccountsApiBalanceFetcher', () => { // Verify both API balances and staked balance processing occurred expect(mockFetchMultiChainBalancesV4).toHaveBeenCalled(); expect(mockGetProvider).toHaveBeenCalledWith(MOCK_CHAIN_ID); - expect(result.length).toBeGreaterThan(0); + expect(result.balances.length).toBeGreaterThan(0); }); it('should handle native balance tracking and guarantee (lines 304-306, 322-338)', async () => { @@ -1263,7 +1389,9 @@ describe('AccountsApiBalanceFetcher', () => { }); // Should have guaranteed native balances for both addresses - const nativeBalances = result.filter((r) => r.token === ZERO_ADDRESS); + const nativeBalances = result.balances.filter( + (r) => r.token === ZERO_ADDRESS, + ); expect(nativeBalances).toHaveLength(2); const addr1Native = nativeBalances.find( @@ -1336,7 +1464,7 @@ describe('AccountsApiBalanceFetcher', () => { }); // Should include staked balance - const stakedBalance = result.find( + const stakedBalance = result.balances.find( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); expect(stakedBalance).toBeDefined(); @@ -1362,7 +1490,7 @@ describe('AccountsApiBalanceFetcher', () => { }); // Should include failed staked balance when contract calls fail - const stakedBalance = result.find( + const stakedBalance = result.balances.find( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); expect(stakedBalance).toBeDefined(); @@ -1396,7 +1524,7 @@ describe('AccountsApiBalanceFetcher', () => { }); // Should include failed staked balance when conversion fails - const stakedBalance = result.find( + const stakedBalance = result.balances.find( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); expect(stakedBalance).toBeDefined(); @@ -1425,7 +1553,7 @@ describe('AccountsApiBalanceFetcher', () => { }); // Should include staked balance with zero value when shares are zero - const stakedBalance = result.find( + const stakedBalance = result.balances.find( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); expect(stakedBalance).toBeDefined(); @@ -1466,7 +1594,7 @@ describe('AccountsApiBalanceFetcher', () => { }); // Should include staked balance entries for both addresses - const stakedBalances = result.filter( + const stakedBalances = result.balances.filter( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); expect(stakedBalances).toHaveLength(2); @@ -1538,18 +1666,20 @@ describe('AccountsApiBalanceFetcher', () => { // With safelyExecuteWithTimeout, API failures are handled gracefully // We should have successful staked balance + native token guarantee (no explicit error entries) - const successfulEntries = result.filter((r) => r.success); - const stakedEntries = result.filter( + const successfulEntries = result.balances.filter((r) => r.success); + const stakedEntries = result.balances.filter( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); - const nativeEntries = result.filter((r) => r.token === ZERO_ADDRESS); + const nativeEntries = result.balances.filter( + (r) => r.token === ZERO_ADDRESS, + ); expect(successfulEntries.length).toBeGreaterThan(0); // Staked balance + native token succeeded expect(stakedEntries).toHaveLength(1); // Should have staked balance entry expect(nativeEntries).toHaveLength(1); // Should have native token guarantee // Should not throw since we have some successful results - expect(result.length).toBeGreaterThan(0); + expect(result.balances.length).toBeGreaterThan(0); } finally { consoleSpy.mockRestore(); } @@ -1590,9 +1720,9 @@ describe('AccountsApiBalanceFetcher', () => { allAccounts: MOCK_INTERNAL_ACCOUNTS, }); - expect(result).toHaveLength(2); // PEPE token + native token guarantee + expect(result.balances).toHaveLength(2); // PEPE token + native token guarantee - const pepeBalance = result.find( + const pepeBalance = result.balances.find( (r) => r.token === '0x25d887ce7a35172c62febfd67a1856f20faebb00', ); expect(pepeBalance).toBeDefined(); @@ -1635,7 +1765,7 @@ describe('AccountsApiBalanceFetcher', () => { allAccounts: MOCK_INTERNAL_ACCOUNTS, }); - const daiBalance = result.find( + const daiBalance = result.balances.find( (r) => r.token === '0x6B175474E89094C44Da98b954EedeAC495271d0F', ); expect(daiBalance?.success).toBe(true); @@ -1674,7 +1804,7 @@ describe('AccountsApiBalanceFetcher', () => { allAccounts: MOCK_INTERNAL_ACCOUNTS, }); - const usdcBalance = result.find( + const usdcBalance = result.balances.find( (r) => r.token === '0xA0b86a33E6441c86c33E1C6B9cD964c0BA2A86B', ); expect(usdcBalance?.success).toBe(true); @@ -1713,7 +1843,7 @@ describe('AccountsApiBalanceFetcher', () => { allAccounts: MOCK_INTERNAL_ACCOUNTS, }); - const usdcBalance = result.find( + const usdcBalance = result.balances.find( (r) => r.token === '0xA0b86a33E6441c86c33E1C6B9cD964c0BA2A86B', ); expect(usdcBalance?.success).toBe(true); @@ -1750,7 +1880,7 @@ describe('AccountsApiBalanceFetcher', () => { allAccounts: MOCK_INTERNAL_ACCOUNTS, }); - const shibBalance = result.find( + const shibBalance = result.balances.find( (r) => r.token === '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE', ); expect(shibBalance?.success).toBe(true); @@ -1790,7 +1920,7 @@ describe('AccountsApiBalanceFetcher', () => { allAccounts: MOCK_INTERNAL_ACCOUNTS, }); - const daiBalance = result.find( + const daiBalance = result.balances.find( (r) => r.token === '0x6B175474E89094C44Da98b954EedeAC495271d0F', ); expect(daiBalance?.success).toBe(true); @@ -1825,7 +1955,7 @@ describe('AccountsApiBalanceFetcher', () => { allAccounts: MOCK_INTERNAL_ACCOUNTS, }); - const daiBalance = result.find( + const daiBalance = result.balances.find( (r) => r.token === '0x6B175474E89094C44Da98b954EedeAC495271d0F', ); expect(daiBalance?.success).toBe(true); @@ -1863,7 +1993,7 @@ describe('AccountsApiBalanceFetcher', () => { allAccounts: MOCK_INTERNAL_ACCOUNTS, }); - const daiBalance = result.find( + const daiBalance = result.balances.find( (r) => r.token === '0x6B175474E89094C44Da98b954EedeAC495271d0F', ); expect(daiBalance?.success).toBe(true); @@ -1891,17 +2021,15 @@ describe('AccountsApiBalanceFetcher', () => { it('should throw error when API fails and no successful results exist (line 400)', async () => { const mockApiError = new Error('Complete API failure'); - // Mock safelyExecuteWithTimeout to throw (this will trigger the catch block and set apiError = true) - mockSafelyExecuteWithTimeout.mockImplementation(async () => { - throw mockApiError; - }); + // Mock fetchMultiChainBalancesV4 to throw (this will trigger the catch block and set apiError = true) + mockFetchMultiChainBalancesV4.mockRejectedValue(mockApiError); // Create a balance fetcher WITHOUT staking provider to avoid successful staked balances const balanceFetcherNoStaking = new AccountsApiBalanceFetcher( 'extension', ); - // This should trigger the error throw on line 400 + // This should trigger the error throw on line 412 (was 400 before) await expect( balanceFetcherNoStaking.fetch({ chainIds: [MOCK_CHAIN_ID], 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 d03005f9ce8..d61657be848 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 @@ -33,6 +33,11 @@ export type ProcessedBalance = { chainId: ChainIdHex; }; +export type BalanceFetchResult = { + balances: ProcessedBalance[]; + unprocessedChainIds?: ChainIdHex[]; +}; + export type BalanceFetcher = { supports(chainId: ChainIdHex): boolean; fetch(input: { @@ -40,7 +45,7 @@ export type BalanceFetcher = { queryAllAccounts: boolean; selectedAccount: ChecksumAddress; allAccounts: InternalAccount[]; - }): Promise; + }): Promise; }; const checksum = (addr: string): ChecksumAddress => @@ -205,11 +210,10 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { async #fetchBalances(addrs: CaipAccountAddress[]) { // If we have fewer than or equal to the batch size, make a single request if (addrs.length <= ACCOUNTS_API_BATCH_SIZE) { - const { balances } = await fetchMultiChainBalancesV4( + return await fetchMultiChainBalancesV4( { accountAddresses: addrs }, this.#platform, ); - return balances; } // Otherwise, batch the requests to respect the 50-element limit @@ -217,6 +221,9 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { ReturnType >['balances'][number]; + type ResponseData = Awaited>; + + const allUnprocessedNetworks = new Set(); const allBalances = await reduceInBatchesSerially< CaipAccountAddress, BalanceData[] @@ -224,16 +231,25 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { values: addrs, batchSize: ACCOUNTS_API_BATCH_SIZE, eachBatch: async (workingResult, batch) => { - const { balances } = await fetchMultiChainBalancesV4( + const response = await fetchMultiChainBalancesV4( { accountAddresses: batch }, this.#platform, ); - return [...(workingResult || []), ...balances]; + // Collect unprocessed networks from each batch + if (response.unprocessedNetworks) { + response.unprocessedNetworks.forEach((network) => + allUnprocessedNetworks.add(network), + ); + } + return [...(workingResult || []), ...response.balances]; }, initialResult: [], }); - return allBalances; + return { + balances: allBalances, + unprocessedNetworks: Array.from(allUnprocessedNetworks), + } as ResponseData; } async fetch({ @@ -241,7 +257,7 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { queryAllAccounts, selectedAccount, allAccounts, - }: Parameters[0]): Promise { + }: Parameters[0]): Promise { const caipAddrs: CaipAccountAddress[] = []; for (const chainId of chainIds.filter((c) => this.supports(c))) { @@ -255,22 +271,30 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { } if (!caipAddrs.length) { - return []; + return { balances: [] }; } // Don't use safelyExecute here - let real errors propagate - let balances; + let apiResponse; let apiError = false; try { - balances = await this.#fetchBalances(caipAddrs); + apiResponse = await this.#fetchBalances(caipAddrs); } catch (error) { // Mark that we had an API error so we don't add fake zero balances apiError = true; console.error('Failed to fetch balances from API:', error); - balances = undefined; + apiResponse = undefined; } + // Extract unprocessed networks and convert to hex chain IDs + const unprocessedChainIds: ChainIdHex[] | undefined = + apiResponse?.unprocessedNetworks + ? apiResponse.unprocessedNetworks.map( + (chainId) => toHex(chainId) as ChainIdHex, + ) + : undefined; + const stakedBalances = await this.#fetchStakedBalances(caipAddrs); const results: ProcessedBalance[] = []; @@ -294,8 +318,8 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { const nativeBalancesFromAPI = new Map(); // key: `${address}-${chainId}` // Process regular API balances - if (balances) { - const apiBalances = balances.flatMap((b) => { + if (apiResponse?.balances) { + const apiBalances = apiResponse.balances.flatMap((b) => { const addressPart = b.accountAddress?.split(':')[2]; if (!addressPart) { return []; @@ -389,6 +413,9 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { throw new Error('Failed to fetch any balance data due to API error'); } - return results; + return { + balances: results, + unprocessedChainIds, + }; } } diff --git a/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.test.ts b/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.test.ts index a8ac8561c98..717541724c9 100644 --- a/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.test.ts +++ b/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.test.ts @@ -231,7 +231,7 @@ describe('RpcBalanceFetcher', () => { allAccounts: MOCK_INTERNAL_ACCOUNTS, }); - expect(result).toStrictEqual([]); + expect(result).toStrictEqual({ balances: [] }); expect(mockGetTokensState).not.toHaveBeenCalled(); expect(mockGetProvider).not.toHaveBeenCalled(); }); @@ -282,10 +282,10 @@ describe('RpcBalanceFetcher', () => { ); // Should return all balances from the mock (DAI for both accounts + USDC + ETH for both) - expect(result.length).toBeGreaterThan(0); + expect(result.balances.length).toBeGreaterThan(0); // Check that we get balances for the selected account - const address1Balances = result.filter( + const address1Balances = result.balances.filter( (r) => r.account === MOCK_ADDRESS_1, ); expect(address1Balances.length).toBeGreaterThan(0); @@ -322,7 +322,7 @@ describe('RpcBalanceFetcher', () => { ); // Should return all balances from the mock - expect(result.length).toBeGreaterThan(0); + expect(result.balances.length).toBeGreaterThan(0); }); it('should handle multiple chain IDs', async () => { @@ -347,7 +347,7 @@ describe('RpcBalanceFetcher', () => { }); // Check that we have failed balances (null values) - const failedBalances = result.filter((r) => !r.success); + const failedBalances = result.balances.filter((r) => !r.success); expect(failedBalances.length).toBeGreaterThan(0); // Verify the failed balance structure @@ -375,7 +375,7 @@ describe('RpcBalanceFetcher', () => { }); // Even with no tokens, native token and staked balances will still be processed - expect(result.length).toBeGreaterThan(0); + expect(result.balances.length).toBeGreaterThan(0); expect(mockGetProvider).toHaveBeenCalled(); expect(mockGetTokenBalancesForMultipleAddresses).toHaveBeenCalled(); }); @@ -407,7 +407,7 @@ describe('RpcBalanceFetcher', () => { // With parallel processing and safelyExecuteWithTimeout, errors are caught gracefully // and an empty array is returned for failed chains - expect(result).toStrictEqual([]); + expect(result).toStrictEqual({ balances: [] }); }); it('should handle multicall errors gracefully', async () => { @@ -424,7 +424,7 @@ describe('RpcBalanceFetcher', () => { // With parallel processing and safelyExecuteWithTimeout, errors are caught gracefully // and an empty array is returned for failed chains - expect(result).toStrictEqual([]); + expect(result).toStrictEqual({ balances: [] }); }); it('should handle timeout gracefully when safelyExecuteWithTimeout returns undefined', async () => { @@ -439,7 +439,7 @@ describe('RpcBalanceFetcher', () => { }); // Should return empty array when timeout occurs - expect(result).toStrictEqual([]); + expect(result).toStrictEqual({ balances: [] }); expect(mockSafelyExecuteWithTimeout).toHaveBeenCalled(); }); @@ -460,8 +460,10 @@ describe('RpcBalanceFetcher', () => { }); // Should return results only from the successful chain - expect(result.length).toBeGreaterThan(0); - expect(result.every((r) => r.chainId === MOCK_CHAIN_ID_2)).toBe(true); + expect(result.balances.length).toBeGreaterThan(0); + expect(result.balances.every((r) => r.chainId === MOCK_CHAIN_ID_2)).toBe( + true, + ); }); }); @@ -480,7 +482,7 @@ describe('RpcBalanceFetcher', () => { }); // Even with no tokens, native token and staked balances will still be processed - expect(result.length).toBeGreaterThan(0); + expect(result.balances.length).toBeGreaterThan(0); expect(mockGetTokenBalancesForMultipleAddresses).toHaveBeenCalled(); }); @@ -679,7 +681,7 @@ describe('RpcBalanceFetcher', () => { }); // Should include staked balance for the selected account only (queryAllAccounts: false) - const stakingResults = result.filter( + const stakingResults = result.balances.filter( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); const stakedBalance1 = stakingResults.find( @@ -714,7 +716,7 @@ describe('RpcBalanceFetcher', () => { }); // Should still include staked balance entries with zero values - const stakingResults = result.filter( + const stakingResults = result.balances.filter( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); const stakedBalance = stakingResults.find( @@ -735,7 +737,7 @@ describe('RpcBalanceFetcher', () => { }); // Should include staked balances for all accounts when queryAllAccounts: true - const stakedBalances = result.filter( + const stakedBalances = result.balances.filter( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); @@ -765,7 +767,7 @@ describe('RpcBalanceFetcher', () => { }); // Should not include any staking balances for unsupported chains - const stakedBalances = result.filter( + const stakedBalances = result.balances.filter( (r) => r.token === STAKING_CONTRACT_ADDRESS, ); @@ -792,7 +794,9 @@ describe('RpcBalanceFetcher', () => { }); // Should still include native token entry with zero value - const nativeResults = result.filter((r) => r.token === ZERO_ADDRESS); + const nativeResults = result.balances.filter( + (r) => r.token === ZERO_ADDRESS, + ); const nativeBalance = nativeResults.find( (r) => r.account === MOCK_ADDRESS_1, ); @@ -811,7 +815,9 @@ describe('RpcBalanceFetcher', () => { }); // Should include native balances for all accounts - const nativeBalances = result.filter((r) => r.token === ZERO_ADDRESS); + const nativeBalances = result.balances.filter( + (r) => r.token === ZERO_ADDRESS, + ); expect(nativeBalances).toHaveLength(2); diff --git a/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.ts b/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.ts index ed53bf0960b..71990f78aff 100644 --- a/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.ts +++ b/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.ts @@ -25,6 +25,11 @@ export type ProcessedBalance = { chainId: ChainIdHex; }; +export type BalanceFetchResult = { + balances: ProcessedBalance[]; + unprocessedChainIds?: ChainIdHex[]; +}; + export type BalanceFetcher = { supports(chainId: ChainIdHex): boolean; fetch(input: { @@ -32,7 +37,7 @@ export type BalanceFetcher = { queryAllAccounts: boolean; selectedAccount: ChecksumAddress; allAccounts: InternalAccount[]; - }): Promise; + }): Promise; }; const ZERO_ADDRESS = @@ -79,7 +84,7 @@ export class RpcBalanceFetcher implements BalanceFetcher { queryAllAccounts, selectedAccount, allAccounts, - }: Parameters[0]): Promise { + }: Parameters[0]): Promise { // Process all chains in parallel for better performance const chainProcessingPromises = chainIds.map(async (chainId) => { const tokensState = this.#getTokensState(); @@ -194,7 +199,7 @@ export class RpcBalanceFetcher implements BalanceFetcher { } }); - return results; + return { balances: results }; } /**