diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index c7c88ae779..3bd231e8d7 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `AccountsApiDataSourceConfig.useBalanceV6` feature flag getter (`() => boolean`, default `() => false`) that switches the Accounts API balances endpoint from v5 to v6; the flag is read per fetch so it can be toggled at runtime to revert v6 -> v5 without re-instantiating the data source. Only `category: 'token'` rows from the v6 response are consumed (DeFi positions are ignored) to preserve parity with v5 +- Add `includeTokens` option to `AssetsController.getAssets` — a list of custom ERC-20 CAIP-19 asset IDs to include in the fetch on top of the accounts' stored custom assets. These are passed to the Accounts API v6 balances endpoint as `includeAssetIds` (to confirm detection) and are fetched via RPC as custom assets + ### Changed - Bump `@metamask/transaction-controller` from `^68.2.0` to `^68.2.1` ([#9337](https://github.com/MetaMask/core/pull/9337)) diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 3222a8d285..4ec7ccbe16 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -18,6 +18,7 @@ import type { AssetsControllerMessenger, AssetsControllerState, } from './AssetsController'; +import type { AccountsApiDataSourceConfig } from './data-sources/AccountsApiDataSource'; import type { PriceDataSourceConfig } from './data-sources/PriceDataSource'; import { PriceDataSource } from './data-sources/PriceDataSource'; import { TokenDataSource } from './data-sources/TokenDataSource'; @@ -124,6 +125,7 @@ type WithControllerOptions = { controllerOptions?: Partial<{ trace: TraceCallback; priceDataSourceConfig: PriceDataSourceConfig; + accountsApiDataSourceConfig: AccountsApiDataSourceConfig; isEnabled: () => boolean; }>; }; @@ -758,6 +760,56 @@ describe('AssetsController', () => { }); }); + it('passes includeTokens to the Accounts API v6 endpoint as includeAssetIds', async () => { + const fetchV6MultiAccountBalances = jest.fn().mockResolvedValue({ + accounts: [], + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + }); + + const queryApiClient = { + ...createMockQueryApiClient(), + accounts: { + fetchV2SupportedNetworks: jest.fn().mockResolvedValue({ + fullSupport: [1], + partialSupport: [], + }), + fetchV6MultiAccountBalances, + fetchV5MultiAccountBalances: jest.fn().mockResolvedValue({ + balances: [], + unprocessedNetworks: [], + }), + }, + } as unknown as ApiPlatformClient; + + await withController( + { + queryApiClient, + controllerOptions: { + accountsApiDataSourceConfig: { useBalanceV6: () => true }, + }, + }, + async ({ controller }) => { + // Let the data source initialize its active chains. + await flushPromises(); + + await controller.getAssets([createMockInternalAccount()], { + chainIds: ['eip155:1'], + forceUpdate: true, + includeTokens: [MOCK_ASSET_ID], + }); + + expect(fetchV6MultiAccountBalances).toHaveBeenCalledWith( + expect.arrayContaining([ + `eip155:1:0x1234567890123456789012345678901234567890`, + ]), + { includeAssetIds: [MOCK_ASSET_ID] }, + expect.anything(), + ); + }, + ); + }); + describe('pipeline splitting', () => { it('returns from getAssets before background pipelines complete', async () => { // Spy on handleAssetsUpdate to count how many times state is written. diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index cd16591df8..a099d44b04 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1591,6 +1591,14 @@ export class AssetsController extends BaseController< assetsForPriceUpdate?: Caip19AssetId[]; /** When set to `'merge'`, fetch result is merged with existing state instead of replacing. Use for partial fetches (e.g. newly added chains). */ updateMode?: AssetsUpdateMode; + /** + * Additional custom ERC-20 tokens to include in this fetch, on top of the + * accounts' stored custom assets. These are passed to the Accounts API v6 + * balances endpoint as `includeAssetIds` (to confirm detection) and are + * fetched via RPC as custom assets. Ignored when v6 is disabled for the + * API path, but still fetched via RPC. + */ + includeTokens?: Caip19AssetId[]; }, ): Promise>> { const chainIds = options?.chainIds ?? [...this.#enabledChains]; @@ -1601,12 +1609,19 @@ export class AssetsController extends BaseController< return this.#getAssetsFromState(accounts, chainIds, assetTypes); } - // Collect custom assets for all requested accounts - const customAssets: Caip19AssetId[] = []; + // Collect custom assets for all requested accounts, plus any explicit + // includeTokens passed by the caller. Deduplicated so the same token is not + // sent twice to the Accounts API / RPC. + const customAssetsSet = new Set(); for (const account of accounts) { - const accountCustomAssets = this.getCustomAssets(account.id); - customAssets.push(...accountCustomAssets); + for (const accountCustomAsset of this.getCustomAssets(account.id)) { + customAssetsSet.add(accountCustomAsset); + } + } + for (const includeToken of options?.includeTokens ?? []) { + customAssetsSet.add(includeToken); } + const customAssets: Caip19AssetId[] = [...customAssetsSet]; if (options?.forceUpdate) { const startTime = performance.now(); diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index 645b0a44de..8b769e54ac 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -1,11 +1,12 @@ /* eslint-disable jest/unbound-method */ -import type { V5BalanceItem } from '@metamask/core-backend'; +import type { V5BalanceItem, V6BalanceItem } from '@metamask/core-backend'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace } from '@metamask/messenger'; import type { ChainId, + Caip19AssetId, DataRequest, Context, AssetsControllerStateInternal, @@ -32,9 +33,15 @@ type MockApiClient = { accounts: { fetchV2SupportedNetworks: jest.Mock; fetchV5MultiAccountBalances: jest.Mock; + fetchV6MultiAccountBalances: jest.Mock; }; }; +type V6AccountEntry = { + accountId: string; + balances: V6BalanceItem[]; +}; + function createMockAccount( overrides?: Partial, ): InternalAccount { @@ -59,6 +66,7 @@ function createMockApiClient( supportedChains: number[] = [1, 137], balances: V5BalanceItem[] = [], unprocessedNetworks: string[] = [], + v6Accounts: V6AccountEntry[] = [], ): MockApiClient { return { accounts: { @@ -70,10 +78,23 @@ function createMockApiClient( balances, unprocessedNetworks, }), + fetchV6MultiAccountBalances: jest.fn().mockResolvedValue({ + accounts: v6Accounts, + unprocessedNetworks, + unprocessedIncludeAssetIds: [], + }), }, }; } +function createMockV6BalanceItem( + assetId: string, + balance: string, + category: 'token' | 'defi' = 'token', +): V6BalanceItem { + return { category, assetId, balance } as V6BalanceItem; +} + function createMockBalanceItem( accountId: string, assetId: string, @@ -122,6 +143,8 @@ async function setupController( balances?: V5BalanceItem[]; unprocessedNetworks?: string[]; fetchTimeoutMs?: number; + v6Accounts?: V6AccountEntry[]; + useBalanceV6?: () => boolean; } = {}, ): Promise { const { @@ -129,6 +152,8 @@ async function setupController( balances = [], unprocessedNetworks = [], fetchTimeoutMs, + v6Accounts = [], + useBalanceV6, } = options; const rootMessenger = new Messenger({ @@ -158,6 +183,7 @@ async function setupController( supportedChains, balances, unprocessedNetworks, + v6Accounts, ); const controller = new AccountsApiDataSource({ @@ -166,6 +192,7 @@ async function setupController( onActiveChainsUpdated: (dataSourceName, chains, previousChains): void => activeChainsUpdateHandler(dataSourceName, chains, previousChains), ...(fetchTimeoutMs === undefined ? {} : { fetchTimeoutMs }), + ...(useBalanceV6 === undefined ? {} : { useBalanceV6 }), }); // Wait for async initialization @@ -402,6 +429,216 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); + describe('useBalanceV6 feature flag', () => { + it('uses the v5 endpoint by default', async () => { + const { controller, apiClient } = await setupController(); + + await controller.fetch(createDataRequest()); + + expect( + apiClient.accounts.fetchV5MultiAccountBalances, + ).toHaveBeenCalledTimes(1); + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).not.toHaveBeenCalled(); + + controller.destroy(); + }); + + it('uses the v6 endpoint when the flag returns true', async () => { + const { controller, apiClient } = await setupController({ + useBalanceV6: () => true, + }); + + await controller.fetch(createDataRequest()); + + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledWith([`eip155:1:${MOCK_ADDRESS}`], undefined, undefined); + expect( + apiClient.accounts.fetchV5MultiAccountBalances, + ).not.toHaveBeenCalled(); + + controller.destroy(); + }); + + it('reads the flag per fetch so it can revert to v5 at runtime', async () => { + let v6Enabled = true; + const { controller, apiClient } = await setupController({ + useBalanceV6: () => v6Enabled, + }); + + await controller.fetch(createDataRequest()); + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledTimes(1); + + v6Enabled = false; + await controller.fetch(createDataRequest()); + expect( + apiClient.accounts.fetchV5MultiAccountBalances, + ).toHaveBeenCalledTimes(1); + + controller.destroy(); + }); + + it('processes v6 token balances grouped by account', async () => { + const { controller } = await setupController({ + useBalanceV6: () => true, + v6Accounts: [ + { + accountId: `eip155:1:${MOCK_ADDRESS}`, + balances: [ + createMockV6BalanceItem( + 'eip155:1/slip44:60', + '1000000000000000000', + ), + ], + }, + ], + }); + + const response = await controller.fetch(createDataRequest()); + + expect( + response.assetsBalance?.['mock-account-id']?.['eip155:1/slip44:60'] + ?.amount, + ).toBe('1000000000000000000'); + + controller.destroy(); + }); + + it('ignores v6 defi positions', async () => { + const { controller } = await setupController({ + useBalanceV6: () => true, + v6Accounts: [ + { + accountId: `eip155:1:${MOCK_ADDRESS}`, + balances: [ + createMockV6BalanceItem( + 'eip155:1/slip44:60', + '1000000000000000000', + ), + createMockV6BalanceItem( + 'eip155:1/erc20:0xdefi', + '500', + 'defi', + ), + ], + }, + ], + }); + + const response = await controller.fetch(createDataRequest()); + + const accountBalances = response.assetsBalance?.['mock-account-id'] ?? {}; + expect(accountBalances).toHaveProperty('eip155:1/slip44:60'); + expect(accountBalances).not.toHaveProperty('eip155:1/erc20:0xdefi'); + + controller.destroy(); + }); + + it('marks v6 unprocessed networks as errors', async () => { + const { controller } = await setupController({ + useBalanceV6: () => true, + unprocessedNetworks: ['eip155:1'], + }); + + const response = await controller.fetch(createDataRequest()); + + expect(response.errors?.[CHAIN_MAINNET]).toBe( + 'Unprocessed by Accounts API', + ); + + controller.destroy(); + }); + + it('handles v6 API errors', async () => { + const { controller, apiClient } = await setupController({ + useBalanceV6: () => true, + }); + + apiClient.accounts.fetchV6MultiAccountBalances.mockRejectedValueOnce( + new Error('API Error'), + ); + + const response = await controller.fetch(createDataRequest()); + + expect(response.errors?.[CHAIN_MAINNET]).toContain('Fetch failed'); + + controller.destroy(); + }); + + it('passes custom ERC-20 tokens to v6 as includeAssetIds', async () => { + const { controller, apiClient } = await setupController({ + useBalanceV6: () => true, + }); + + const customToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + await controller.fetch( + createDataRequest({ customAssets: [customToken] }), + ); + + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + { includeAssetIds: [customToken] }, + undefined, + ); + + controller.destroy(); + }); + + it('filters out native and non-erc20 custom assets from includeAssetIds', async () => { + const { controller, apiClient } = await setupController({ + useBalanceV6: () => true, + }); + + const erc20Token = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + const nativeAsset = 'eip155:1/slip44:60' as Caip19AssetId; + + await controller.fetch( + createDataRequest({ customAssets: [erc20Token, nativeAsset] }), + ); + + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + { includeAssetIds: [erc20Token] }, + undefined, + ); + + controller.destroy(); + }); + + it('excludes custom tokens on chains not part of the fetch', async () => { + const { controller, apiClient } = await setupController({ + useBalanceV6: () => true, + }); + + const otherChainToken = + 'eip155:137/erc20:0x0000000000000000000000000000000000001010' as Caip19AssetId; + + await controller.fetch( + createDataRequest({ + chainIds: [CHAIN_MAINNET], + customAssets: [otherChainToken], + }), + ); + + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledWith([`eip155:1:${MOCK_ADDRESS}`], undefined, undefined); + + controller.destroy(); + }); + }); + it('fetch marks every requested chain as errored when the call exceeds the configured timeout', async () => { const { controller, apiClient } = await setupController({ fetchTimeoutMs: 10, diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 74f5d94be1..13ea5a146e 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -1,8 +1,9 @@ -import type { V5BalanceItem } from '@metamask/core-backend'; +import type { V5BalanceItem, V6BalanceItem } from '@metamask/core-backend'; import { ApiPlatformClient } from '@metamask/core-backend'; import { isCaipChainId, KnownCaipNamespace, + parseCaipAssetType, toCaipChainId, } from '@metamask/utils'; @@ -71,6 +72,16 @@ export type AccountsApiDataSourceConfig = { * middleware hands them off to the next data source (e.g. RPC fallback). */ fetchTimeoutMs?: number; + /** + * Feature flag gating the Accounts API balances endpoint version + * (default: () => false, i.e. v5). + * + * When it returns true, balances are fetched from the v6 endpoint; + * otherwise the legacy v5 endpoint is used. Kept as a getter so the value + * can be driven by a remote feature flag and toggled at runtime, allowing a + * revert to v5 without re-instantiating the data source. + */ + useBalanceV6?: () => boolean; }; export type AccountsApiDataSourceOptions = AccountsApiDataSourceConfig & { @@ -192,6 +203,12 @@ export class AccountsApiDataSource extends AbstractDataSource< /** Getter avoids stale value when user toggles token detection at runtime. */ readonly #tokenDetectionEnabled: () => boolean; + /** + * Feature flag getter selecting the balances endpoint version. Kept as a + * getter so it can be flipped at runtime (e.g. to revert v6 -> v5). + */ + readonly #useBalanceV6: () => boolean; + /** ApiPlatformClient for cached API calls */ readonly #apiClient: ApiPlatformClient; @@ -212,6 +229,7 @@ export class AccountsApiDataSource extends AbstractDataSource< this.#fetchTimeoutMs = options.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; this.#tokenDetectionEnabled = options.tokenDetectionEnabled ?? ((): boolean => true); + this.#useBalanceV6 = options.useBalanceV6 ?? ((): boolean => false); this.#apiClient = options.queryApiClient; this.#initializeActiveChains().catch(console.error); @@ -334,20 +352,20 @@ export class AccountsApiDataSource extends AbstractDataSource< ? { staleTime: 0, gcTime: 0 } : undefined; - const apiResponse = await fetchWithTimeout( - () => - this.#apiClient.accounts.fetchV5MultiAccountBalances( + // Feature-flagged: v6 endpoint with a fallback to legacy v5. The flag is + // read here (not cached) so a runtime toggle can revert v6 -> v5. + const { unprocessedNetworks, assetsBalance } = this.#useBalanceV6() + ? await this.#fetchV6Balances( accountIds, - undefined, fetchOptions, - ), - this.#fetchTimeoutMs, - ); + request, + this.#getIncludeAssetIds(request, chainsToFetch), + ) + : await this.#fetchV5Balances(accountIds, fetchOptions, request); // Handle unprocessed networks - these will be passed to next middleware - if (apiResponse.unprocessedNetworks.length > 0) { - const unprocessedChainIds = - apiResponse.unprocessedNetworks.map(caipChainIdToChainId); + if (unprocessedNetworks.length > 0) { + const unprocessedChainIds = unprocessedNetworks.map(caipChainIdToChainId); // Add unprocessed chains to errors so middleware passes them to next data source response.errors = response.errors ?? {}; @@ -356,11 +374,6 @@ export class AccountsApiDataSource extends AbstractDataSource< } } - const { assetsBalance } = this.#processV5Balances( - apiResponse.balances, - request, - ); - response.assetsBalance = assetsBalance; response.updateMode = 'merge'; } catch (error) { @@ -390,6 +403,140 @@ export class AccountsApiDataSource extends AbstractDataSource< return response; } + /** + * Fetch balances from the legacy v5 endpoint and process them. + * + * @param accountIds - CAIP-10 account IDs to fetch balances for. + * @param fetchOptions - Cache/fetch options (e.g. force update settings). + * @param request - The original data request containing accounts to map. + * @returns Unprocessed networks and processed asset balances by account. + */ + async #fetchV5Balances( + accountIds: string[], + fetchOptions: { staleTime: number; gcTime: number } | undefined, + request: DataRequest, + ): Promise<{ + unprocessedNetworks: string[]; + assetsBalance: Record>; + }> { + const apiResponse = await fetchWithTimeout( + () => + this.#apiClient.accounts.fetchV5MultiAccountBalances( + accountIds, + undefined, + fetchOptions, + ), + this.#fetchTimeoutMs, + ); + + const { assetsBalance } = this.#processV5Balances( + apiResponse.balances, + request, + ); + + return { + unprocessedNetworks: apiResponse.unprocessedNetworks, + assetsBalance, + }; + } + + /** + * Fetch balances from the v6 endpoint and process them. + * + * @param accountIds - CAIP-10 account IDs to fetch balances for. + * @param fetchOptions - Cache/fetch options (e.g. force update settings). + * @param request - The original data request containing accounts to map. + * @param includeAssetIds - Custom ERC-20 CAIP-19 asset IDs to confirm + * detection for (passed to the v6 endpoint as `includeAssetIds`). + * @returns Unprocessed networks and processed asset balances by account. + */ + async #fetchV6Balances( + accountIds: string[], + fetchOptions: { staleTime: number; gcTime: number } | undefined, + request: DataRequest, + includeAssetIds: string[], + ): Promise<{ + unprocessedNetworks: string[]; + assetsBalance: Record>; + }> { + const apiResponse = await fetchWithTimeout( + () => + this.#apiClient.accounts.fetchV6MultiAccountBalances( + accountIds, + includeAssetIds.length > 0 ? { includeAssetIds } : undefined, + fetchOptions, + ), + this.#fetchTimeoutMs, + ); + + const { assetsBalance } = this.#processV6Balances( + apiResponse.accounts, + request, + ); + + return { + unprocessedNetworks: apiResponse.unprocessedNetworks, + assetsBalance, + }; + } + + /** + * Derive the v6 `includeAssetIds` list from the request's custom assets. + * + * The v6 balances endpoint only accepts ERC-20 CAIP-19 asset IDs here, so + * native (`slip44`) and non-EVM custom assets are filtered out. Assets on + * chains not part of this fetch are also dropped. + * + * @param request - The original data request containing custom assets. + * @param chainsToFetch - Chains being fetched in this request. + * @returns Deduplicated ERC-20 CAIP-19 asset IDs to include. + */ + #getIncludeAssetIds( + request: DataRequest, + chainsToFetch: ChainId[], + ): string[] { + if (!request.customAssets || request.customAssets.length === 0) { + return []; + } + + const chainSet = new Set(chainsToFetch); + const includeAssetIds = new Set(); + + for (const assetId of request.customAssets) { + try { + const parsed = parseCaipAssetType(assetId); + // v6 includeAssetIds only supports ERC-20 tokens. + if (parsed.assetNamespace !== 'erc20') { + continue; + } + const chainId = `${parsed.chain.namespace}:${parsed.chain.reference}`; + if (chainSet.has(chainId)) { + includeAssetIds.add(assetId); + } + } catch { + // Skip unparseable asset IDs + } + } + + return [...includeAssetIds]; + } + + /** + * Build a lookup of lowercased account address to the request's account ID. + * + * @param request - The original data request containing accounts to map. + * @returns Map of lowercase address to account ID. + */ + #buildAddressToAccountIdMap(request: DataRequest): Map { + const addressToAccountId = new Map(); + for (const { account } of request.accountsWithSupportedChains) { + if (account.address) { + addressToAccountId.set(account.address.toLowerCase(), account.id); + } + } + return addressToAccountId; + } + /** * Process V5 API balances response. * V5 returns a flat array of balance items, each with accountId and assetId. @@ -410,12 +557,7 @@ export class AccountsApiDataSource extends AbstractDataSource< > = {}; // Build a map of lowercase addresses to account IDs for efficient lookup - const addressToAccountId = new Map(); - for (const { account } of request.accountsWithSupportedChains) { - if (account.address) { - addressToAccountId.set(account.address.toLowerCase(), account.id); - } - } + const addressToAccountId = this.#buildAddressToAccountIdMap(request); // V5 response: array of { accountId, assetId, balance, ... } for (const item of balances) { @@ -449,6 +591,73 @@ export class AccountsApiDataSource extends AbstractDataSource< return { assetsBalance }; } + /** + * Process V6 API balances response. + * V6 groups balances per account (`accounts: [{ accountId, balances }]`). + * Only `category: 'token'` rows are consumed here to preserve parity with + * the v5 token-balance behavior; DeFi positions are ignored. + * + * @param accounts - Per-account balance entries from the V6 API response. + * @param request - The original data request containing accounts to map. + * @returns Object containing processed asset balances by account. + */ + #processV6Balances( + accounts: { + accountId: string; + balances: V6BalanceItem[]; + }[], + request: DataRequest, + ): { + assetsBalance: Record>; + } { + const assetsBalance: Record< + string, + Record + > = {}; + + // Build a map of lowercase addresses to account IDs for efficient lookup + const addressToAccountId = this.#buildAddressToAccountIdMap(request); + + for (const entry of accounts) { + // Extract address from CAIP-10 account ID (e.g., "eip155:1:0x1234..." -> "0x1234...") + const addressParts = entry.accountId.split(':'); + if (addressParts.length < 3) { + continue; + } + const address = addressParts[2].toLowerCase(); + + // Find the matching account ID from request + const accountId = addressToAccountId.get(address); + if (!accountId) { + // This is normal - API returns balances for all chains, but request may only have one account + continue; + } + + for (const item of entry.balances) { + // Only consume token balances; DeFi positions are handled elsewhere. + if (item.category !== 'token') { + continue; + } + + if (!assetsBalance[accountId]) { + assetsBalance[accountId] = {}; + } + + // Normalize asset ID (checksum EVM addresses for ERC20 tokens) + const normalizedAssetId = normalizeAssetId( + item.assetId as Caip19AssetId, + ); + + // Store balance as returned by API + assetsBalance[accountId][normalizedAssetId] = { + amount: item.balance, + }; + } + } + + return { assetsBalance }; + } + // ============================================================================ // MIDDLEWARE // ============================================================================