diff --git a/README.md b/README.md index a528c55398..be341858b8 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,7 @@ linkStyle default opacity:0.5 assets_controller --> phishing_controller; assets_controller --> polling_controller; assets_controller --> preferences_controller; + assets_controller --> remote_feature_flag_controller; assets_controller --> transaction_controller; assets_controllers --> account_tree_controller; assets_controllers --> accounts_controller; diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 3e0a758c56..6f04635e67 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add Robinhood Chain (`4663`/`0x1237`) in `MulticallClient` ([#9443](https://github.com/MetaMask/core/pull/9443)) +- `AccountsApiDataSource` now selects the Accounts API balances endpoint version from the `RemoteFeatureFlagController` (`assetsAccountsApiV6` flag, read per fetch off the shared messenger, default v5) so the v6 endpoint is gated consistently across clients (extension, mobile) without each client wiring a getter. The flag is read as a JSON variation shaped `{ value: boolean }` (same shape as `backendWebSocketConnection`). Adds a required `messenger` option and `RemoteFeatureFlagController:getState` to `AccountsApiDataSourceAllowedActions`. Only `category: 'token'` rows from the v6 response are consumed (DeFi positions are ignored) to preserve parity with v5 ([#9344](https://github.com/MetaMask/core/pull/9344)) ### Changed diff --git a/packages/assets-controller/package.json b/packages/assets-controller/package.json index 2fed58b489..558c42ceb6 100644 --- a/packages/assets-controller/package.json +++ b/packages/assets-controller/package.json @@ -74,6 +74,7 @@ "@metamask/phishing-controller": "^17.2.0", "@metamask/polling-controller": "^16.0.8", "@metamask/preferences-controller": "^23.1.0", + "@metamask/remote-feature-flag-controller": "^4.2.2", "@metamask/snaps-controllers": "^19.0.0", "@metamask/snaps-utils": "^12.1.2", "@metamask/transaction-controller": "^68.3.0", diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 8fbd85f741..0adc63a2b7 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'; @@ -121,10 +122,16 @@ type WithControllerOptions = { * Required for tests that rely on asset tracking running (e.g. trace on unlock). */ clientControllerState?: { isUiOpen: boolean }; + /** + * When set, registers RemoteFeatureFlagController:getState so the controller can + * read feature flags (e.g. `assetsAccountsApiV6` gating the balances endpoint). + */ + remoteFeatureFlags?: Record; /** Extra options passed to AssetsController constructor (e.g. trace). */ controllerOptions?: Partial<{ trace: TraceCallback; priceDataSourceConfig: PriceDataSourceConfig; + accountsApiDataSourceConfig: AccountsApiDataSourceConfig; isEnabled: () => boolean; captureException: (error: Error) => void; tempMigrateAssetsInfoMetadataAssets3346: () => Assets3346MigrationState; @@ -156,6 +163,7 @@ async function withController( state = {}, isBasicFunctionality = (): boolean => true, clientControllerState, + remoteFeatureFlags, queryApiClient = createMockQueryApiClient(), controllerOptions = {}, }, @@ -230,6 +238,17 @@ async function withController( ); } + if (remoteFeatureFlags !== undefined) { + ( + messenger as { + registerActionHandler: (a: string, h: () => unknown) => void; + } + ).registerActionHandler('RemoteFeatureFlagController:getState', () => ({ + remoteFeatureFlags, + cacheTimestamp: 0, + })); + } + const controller = new AssetsController({ messenger: messenger as unknown as AssetsControllerMessenger, state, @@ -878,6 +897,51 @@ describe('AssetsController', () => { }); }); + // Endpoint selection from the flag is unit-tested in AccountsApiDataSource; + // this asserts the controller wires its messenger through so the + // `assetsAccountsApiV6` flag drives endpoint selection end-to-end. + it('routes to the Accounts API v6 endpoint when the assetsAccountsApiV6 remote flag is enabled', async () => { + const fetchV6MultiAccountBalances = jest.fn().mockResolvedValue({ + accounts: [], + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + }); + const fetchV5MultiAccountBalances = jest.fn().mockResolvedValue({ + balances: [], + unprocessedNetworks: [], + }); + + const queryApiClient = { + ...createMockQueryApiClient(), + accounts: { + fetchV2SupportedNetworks: jest.fn().mockResolvedValue({ + fullSupport: [1], + partialSupport: [], + }), + fetchV6MultiAccountBalances, + fetchV5MultiAccountBalances, + }, + } as unknown as ApiPlatformClient; + + await withController( + { + queryApiClient, + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }, + async ({ controller }) => { + await flushPromises(); + + await controller.getAssets([createMockInternalAccount()], { + chainIds: ['eip155:1'], + forceUpdate: true, + }); + + expect(fetchV6MultiAccountBalances).toHaveBeenCalled(); + expect(fetchV5MultiAccountBalances).not.toHaveBeenCalled(); + }, + ); + }); + 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 3a6612af09..081856f6b0 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -48,6 +48,7 @@ import type { } from '@metamask/permission-controller'; import { PhishingControllerBulkScanTokensAction } from '@metamask/phishing-controller'; import type { PreferencesControllerStateChangeEvent } from '@metamask/preferences-controller'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { SnapControllerGetRunnableSnapsAction, SnapControllerHandleRequestAction, @@ -321,7 +322,9 @@ type AllowedActions = // BackendWebsocketDataSource | BackendWebSocketServiceActions // PhishingController - | PhishingControllerBulkScanTokensAction; + | PhishingControllerBulkScanTokensAction + // AccountsApiDataSource (Accounts API v6 balances feature flag) + | RemoteFeatureFlagControllerGetStateAction; type AccountTreeControllerStateChangedEvent = ControllerStateChangedEvent< 'AccountTreeController', @@ -914,6 +917,7 @@ export class AssetsController extends BaseController< this.#getAssetType(assetId), }); this.#accountsApiDataSource = new AccountsApiDataSource({ + messenger: this.messenger, queryApiClient, onActiveChainsUpdated: this.#onActiveChainsUpdated, ...accountsApiDataSourceConfig, diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index 645b0a44de..f01829bf15 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -1,11 +1,16 @@ /* eslint-disable jest/unbound-method */ -import type { V5BalanceItem } from '@metamask/core-backend'; +import type { + V5BalanceItem, + V6BalanceItem, + V6AccountBalancesEntry, +} 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,6 +37,7 @@ type MockApiClient = { accounts: { fetchV2SupportedNetworks: jest.Mock; fetchV5MultiAccountBalances: jest.Mock; + fetchV6MultiAccountBalances: jest.Mock; }; }; @@ -59,6 +65,7 @@ function createMockApiClient( supportedChains: number[] = [1, 137], balances: V5BalanceItem[] = [], unprocessedNetworks: string[] = [], + v6Accounts: V6AccountBalancesEntry[] = [], ): MockApiClient { return { accounts: { @@ -70,10 +77,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 +142,8 @@ async function setupController( balances?: V5BalanceItem[]; unprocessedNetworks?: string[]; fetchTimeoutMs?: number; + v6Accounts?: V6AccountBalancesEntry[]; + remoteFeatureFlags?: Record; } = {}, ): Promise { const { @@ -129,6 +151,8 @@ async function setupController( balances = [], unprocessedNetworks = [], fetchTimeoutMs, + v6Accounts = [], + remoteFeatureFlags, } = options; const rootMessenger = new Messenger({ @@ -145,9 +169,23 @@ async function setupController( parent: rootMessenger, }); + if (remoteFeatureFlags !== undefined) { + ( + rootMessenger as unknown as { + registerActionHandler: (a: string, h: () => unknown) => void; + } + ).registerActionHandler('RemoteFeatureFlagController:getState', () => ({ + remoteFeatureFlags, + cacheTimestamp: 0, + })); + } + rootMessenger.delegate({ messenger: controllerMessenger, - actions: [], + actions: + remoteFeatureFlags === undefined + ? [] + : ['RemoteFeatureFlagController:getState'], events: [], }); @@ -158,9 +196,12 @@ async function setupController( supportedChains, balances, unprocessedNetworks, + v6Accounts, ); const controller = new AccountsApiDataSource({ + messenger: + controllerMessenger as unknown as AccountsApiDataSourceOptions['messenger'], queryApiClient: apiClient as unknown as AccountsApiDataSourceOptions['queryApiClient'], onActiveChainsUpdated: (dataSourceName, chains, previousChains): void => @@ -359,7 +400,9 @@ describe('AccountsApiDataSource', () => { ), ]; - const { controller } = await setupController({ balances }); + const { controller } = await setupController({ + balances, + }); const response = await controller.fetch(createDataRequest()); @@ -402,6 +445,223 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); + describe('assetsAccountsApiV6 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 assetsAccountsApiV6 remote flag is enabled', async () => { + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }); + + await controller.fetch(createDataRequest()); + + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledTimes(1); + expect( + apiClient.accounts.fetchV5MultiAccountBalances, + ).not.toHaveBeenCalled(); + + controller.destroy(); + }); + + it('uses the v5 endpoint when the assetsAccountsApiV6 remote flag is disabled', async () => { + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: false } }, + }); + + await controller.fetch(createDataRequest()); + + expect( + apiClient.accounts.fetchV5MultiAccountBalances, + ).toHaveBeenCalledTimes(1); + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).not.toHaveBeenCalled(); + + controller.destroy(); + }); + + it('uses the v5 endpoint when the flag is a plain boolean instead of the JSON value shape', async () => { + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: true }, + }); + + await controller.fetch(createDataRequest()); + + expect( + apiClient.accounts.fetchV5MultiAccountBalances, + ).toHaveBeenCalledTimes(1); + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).not.toHaveBeenCalled(); + + controller.destroy(); + }); + + it('calls the v6 endpoint without extra params when enabled', async () => { + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: 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 () => { + const remoteFeatureFlags = { + assetsAccountsApiV6: { value: true }, + }; + const { controller, apiClient } = await setupController({ + remoteFeatureFlags, + }); + + await controller.fetch(createDataRequest()); + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledTimes(1); + + remoteFeatureFlags.assetsAccountsApiV6 = { value: 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({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: 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({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: 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({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: 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({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: 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('does not pass includeAssetIds to v6 even when custom assets are present', async () => { + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }); + + const customToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + await controller.fetch( + createDataRequest({ customAssets: [customToken] }), + ); + + 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, @@ -462,7 +722,9 @@ describe('AccountsApiDataSource', () => { ), ]; - const { controller } = await setupController({ balances }); + const { controller } = await setupController({ + balances, + }); const next = jest.fn().mockResolvedValue(undefined); const context = createMiddlewareContext(); @@ -577,6 +839,8 @@ describe('AccountsApiDataSource', () => { ); const controllerOptions: AccountsApiDataSourceOptions = { + messenger: + controllerMessenger as unknown as AccountsApiDataSourceOptions['messenger'], queryApiClient: apiClient as unknown as AccountsApiDataSourceOptions['queryApiClient'], onActiveChainsUpdated: (dataSourceName, chains, previousChains): void => diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 74f5d94be1..6ac6167b0e 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -1,11 +1,16 @@ -import type { V5BalanceItem } from '@metamask/core-backend'; +import type { + V5BalanceItem, + V6AccountBalancesEntry, +} from '@metamask/core-backend'; import { ApiPlatformClient } from '@metamask/core-backend'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import { isCaipChainId, KnownCaipNamespace, toCaipChainId, } from '@metamask/utils'; +import type { AssetsControllerMessenger } from '../AssetsController'; import { projectLogger, createModuleLogger } from '../logger'; import type { ChainId, @@ -37,9 +42,11 @@ const log = createModuleLogger(projectLogger, CONTROLLER_NAME); // MESSENGER TYPES // ============================================================================ -// Allowed actions that AccountsApiDataSource can call (none - uses callbacks). -// Note: Uses ApiPlatformClient directly, so no BackendApiClient actions needed -export type AccountsApiDataSourceAllowedActions = never; +// Allowed actions that AccountsApiDataSource can call. Balances are fetched via +// ApiPlatformClient directly (no BackendApiClient actions needed); the messenger +// is only used to read the Accounts API v6 balances feature flag. +export type AccountsApiDataSourceAllowedActions = + RemoteFeatureFlagControllerGetStateAction; // ============================================================================ // STATE @@ -74,6 +81,11 @@ export type AccountsApiDataSourceConfig = { }; export type AccountsApiDataSourceOptions = AccountsApiDataSourceConfig & { + /** + * The AssetsController messenger (shared by all data sources). Used to read + * the `assetsAccountsApiV6` remote feature flag. + */ + messenger: AssetsControllerMessenger; /** ApiPlatformClient for API calls with caching */ queryApiClient: ApiPlatformClient; /** Called when active chains are updated. Pass dataSourceName so the controller knows the source. */ @@ -192,6 +204,9 @@ export class AccountsApiDataSource extends AbstractDataSource< /** Getter avoids stale value when user toggles token detection at runtime. */ readonly #tokenDetectionEnabled: () => boolean; + /** Shared AssetsController messenger, used to read remote feature flags. */ + readonly #messenger: AssetsControllerMessenger; + /** ApiPlatformClient for cached API calls */ readonly #apiClient: ApiPlatformClient; @@ -212,11 +227,41 @@ export class AccountsApiDataSource extends AbstractDataSource< this.#fetchTimeoutMs = options.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; this.#tokenDetectionEnabled = options.tokenDetectionEnabled ?? ((): boolean => true); + this.#messenger = options.messenger; this.#apiClient = options.queryApiClient; this.#initializeActiveChains().catch(console.error); } + /** + * Whether the Accounts API v6 balances endpoint is enabled, read from the + * RemoteFeatureFlagController (`assetsAccountsApiV6`). Read on demand (per + * fetch) rather than cached so the value can be toggled at runtime, and behaves + * the same across clients (extension, mobile). Defaults to `false` when the + * flag is unset or the controller is unavailable. + * + * The flag is a LaunchDarkly JSON variation shaped `{ value: boolean }` (same + * shape as `backendWebSocketConnection`), so the nested `value` is read rather + * than treating the flag as a plain boolean. + * + * @returns `true` when the v6 balances endpoint should be used. + */ + #isBalanceV6Enabled(): boolean { + try { + const { remoteFeatureFlags } = this.#messenger.call( + 'RemoteFeatureFlagController:getState', + ); + const flag = remoteFeatureFlags?.assetsAccountsApiV6; + return ( + typeof flag === 'object' && + flag !== null && + Boolean((flag as { value?: unknown }).value) + ); + } catch { + return false; + } + } + // ============================================================================ // INITIALIZATION // ============================================================================ @@ -334,20 +379,16 @@ export class AccountsApiDataSource extends AbstractDataSource< ? { staleTime: 0, gcTime: 0 } : undefined; - const apiResponse = await fetchWithTimeout( - () => - this.#apiClient.accounts.fetchV5MultiAccountBalances( - accountIds, - undefined, - fetchOptions, - ), - this.#fetchTimeoutMs, - ); + // 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.#isBalanceV6Enabled() + ? await this.#fetchV6Balances(accountIds, fetchOptions, request) + : await this.#fetchV5Balances(accountIds, fetchOptions, request); // Handle unprocessed networks - these will be passed to next middleware - if (apiResponse.unprocessedNetworks.length > 0) { + if (unprocessedNetworks.length > 0) { const unprocessedChainIds = - apiResponse.unprocessedNetworks.map(caipChainIdToChainId); + unprocessedNetworks.map(caipChainIdToChainId); // Add unprocessed chains to errors so middleware passes them to next data source response.errors = response.errors ?? {}; @@ -356,11 +397,6 @@ export class AccountsApiDataSource extends AbstractDataSource< } } - const { assetsBalance } = this.#processV5Balances( - apiResponse.balances, - request, - ); - response.assetsBalance = assetsBalance; response.updateMode = 'merge'; } catch (error) { @@ -390,6 +426,96 @@ 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. + * @returns Unprocessed networks and processed asset balances by account. + */ + async #fetchV6Balances( + accountIds: string[], + fetchOptions: { staleTime: number; gcTime: number } | undefined, + request: DataRequest, + ): Promise<{ + unprocessedNetworks: string[]; + assetsBalance: Record>; + }> { + const apiResponse = await fetchWithTimeout( + () => + this.#apiClient.accounts.fetchV6MultiAccountBalances( + accountIds, + undefined, + fetchOptions, + ), + this.#fetchTimeoutMs, + ); + + const { assetsBalance } = this.#processV6Balances( + apiResponse.accounts, + request, + ); + + return { + unprocessedNetworks: apiResponse.unprocessedNetworks, + assetsBalance, + }; + } + + /** + * 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 +536,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 +570,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: V6AccountBalancesEntry[], + request: DataRequest, + ): { + assetsBalance: Record>; + } { + const assetsBalance = Object.create(null) as 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] = Object.create(null) as Record< + Caip19AssetId, + AssetBalance + >; + } + + // 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 // ============================================================================ diff --git a/packages/assets-controller/tsconfig.build.json b/packages/assets-controller/tsconfig.build.json index f7468098e5..4454b4442b 100644 --- a/packages/assets-controller/tsconfig.build.json +++ b/packages/assets-controller/tsconfig.build.json @@ -16,7 +16,8 @@ { "path": "../network-enablement-controller/tsconfig.build.json" }, { "path": "../permission-controller/tsconfig.build.json" }, { "path": "../phishing-controller/tsconfig.build.json" }, - { "path": "../preferences-controller/tsconfig.build.json" } + { "path": "../preferences-controller/tsconfig.build.json" }, + { "path": "../remote-feature-flag-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"], "exclude": ["**/*.test.ts", "**/__fixtures__/"] diff --git a/packages/assets-controller/tsconfig.json b/packages/assets-controller/tsconfig.json index aa015e2729..731de50f36 100644 --- a/packages/assets-controller/tsconfig.json +++ b/packages/assets-controller/tsconfig.json @@ -13,7 +13,8 @@ { "path": "../messenger" }, { "path": "../network-enablement-controller" }, { "path": "../phishing-controller" }, - { "path": "../preferences-controller" } + { "path": "../preferences-controller" }, + { "path": "../remote-feature-flag-controller" } ], "include": ["../../types", "./src"] } diff --git a/yarn.lock b/yarn.lock index e44825fb42..035eb8ec65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5686,6 +5686,7 @@ __metadata: "@metamask/phishing-controller": "npm:^17.2.0" "@metamask/polling-controller": "npm:^16.0.8" "@metamask/preferences-controller": "npm:^23.1.0" + "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-utils": "npm:^12.1.2" "@metamask/transaction-controller": "npm:^68.3.0"