diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md
index ebdac35665f..9dfb3440daf 100644
--- a/packages/assets-controllers/CHANGELOG.md
+++ b/packages/assets-controllers/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Add `DeFiPositionsControllerV2`, which fetches DeFi positions from the Accounts API v6 multiaccount balances endpoint and stores them in a client-ready shape under `allDeFiPositionsV2` ([#9503](https://github.com/MetaMask/core/pull/9503))
+ - Export `DeFiPositionsControllerV2` and supporting types
- Add `isDeprecated` option to `TokenDetectionController` constructor ([#9362](https://github.com/MetaMask/core/pull/9362))
- When `isDeprecated()` returns `true`, no network requests are sent and entry points bail early at `start`, `detectTokens`, `_executePoll`, `addDetectedTokensViaWs`, and `addDetectedTokensViaPolling`, so no token detection work runs while the controller is disabled.
- The function is re-evaluated on each entry point so it can be toggled at runtime without reconstructing the controller.
diff --git a/packages/assets-controllers/devtools/defi-balances-visualizer.html b/packages/assets-controllers/devtools/defi-balances-visualizer.html
new file mode 100644
index 00000000000..1953ac24856
--- /dev/null
+++ b/packages/assets-controllers/devtools/defi-balances-visualizer.html
@@ -0,0 +1,991 @@
+
+
+
+
+
+
+ DeFi Balances Visualizer
+
+
+
+
+
DeFi Balances Visualizer
+
+ MetaMask multiaccount balances API · DeFi positions across 7 EVM
+ chains
+
+
+
+
+
+
+
+ Fetches Mainnet, Linea, Base, Arbitrum, BNB Chain, Optimism &
+ Polygon. Only defi balances are shown; all data is
+ preserved in the raw response.
+
+
+
+
+
+
+
+
+
+
+
+
+
Raw JSON response
+
+
+
+
+
+
+
diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts
new file mode 100644
index 00000000000..75e687596fd
--- /dev/null
+++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts
@@ -0,0 +1,31 @@
+/**
+ * This file is auto generated.
+ * Do not edit manually.
+ */
+
+import type { DeFiPositionsControllerV2 } from './DeFiPositionsControllerV2';
+
+/**
+ * Fetches DeFi positions for the selected account group. Each account key in
+ * a ready response replaces that account's state (other accounts stay).
+ * Accounts still indexing (`processingDefiPositions`) are skipped so prior
+ * state is kept for them. No-ops when disabled or when the group has no
+ * supported accounts. Caching / spam prevention is handled by the apiClient
+ * TanStack Query cache (keyed by accounts + query options including
+ * `vsCurrency`). Pass `{ forceRefresh: true }` to bypass the cache (e.g.
+ * pull-to-refresh).
+ *
+ * @param options - Optional fetch modifiers.
+ * @param options.forceRefresh - When true, bypass the apiClient cache and
+ * fetch immediately.
+ */
+export type DeFiPositionsControllerV2FetchDeFiPositionsAction = {
+ type: `DeFiPositionsControllerV2:fetchDeFiPositions`;
+ handler: DeFiPositionsControllerV2['fetchDeFiPositions'];
+};
+
+/**
+ * Union of all DeFiPositionsControllerV2 action types.
+ */
+export type DeFiPositionsControllerV2MethodActions =
+ DeFiPositionsControllerV2FetchDeFiPositionsAction;
diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts
new file mode 100644
index 00000000000..b0b10061e49
--- /dev/null
+++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts
@@ -0,0 +1,657 @@
+import { deriveStateFromMetadata } from '@metamask/base-controller';
+import type {
+ ApiPlatformClient,
+ V6BalancesResponse,
+} from '@metamask/core-backend';
+import {
+ BtcAccountType,
+ EthAccountType,
+ SolAccountType,
+ SolMethod,
+ SolScope,
+} from '@metamask/keyring-api';
+import { KeyringTypes } from '@metamask/keyring-controller';
+import type { InternalAccount } from '@metamask/keyring-internal-api';
+import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger';
+import type {
+ MessengerActions,
+ MessengerEvents,
+ MockAnyNamespace,
+} from '@metamask/messenger';
+
+import { createMockInternalAccount } from '../../../accounts-controller/tests/mocks';
+import { DEFI_SUPPORTED_NETWORKS } from './build-defi-balances-query';
+import type { DeFiPositionsControllerV2Messenger } from './DeFiPositionsControllerV2';
+import {
+ DeFiPositionsControllerV2,
+ getDefaultDeFiPositionsControllerV2State,
+} from './DeFiPositionsControllerV2';
+
+const EVM_ADDRESS = '0x0000000000000000000000000000000000000001';
+const SOLANA_ADDRESS = 'So11111111111111111111111111111111111111112';
+
+const GROUP_ACCOUNTS = [
+ createMockInternalAccount({
+ id: 'evm-account-id',
+ address: EVM_ADDRESS,
+ type: EthAccountType.Eoa,
+ }),
+ createMockInternalAccount({
+ id: 'btc-account-id',
+ type: BtcAccountType.P2wpkh,
+ }),
+];
+
+const GROUP_ACCOUNTS_WITH_SOLANA: InternalAccount[] = [
+ ...GROUP_ACCOUNTS,
+ {
+ id: 'solana-account-id',
+ address: SOLANA_ADDRESS,
+ options: {},
+ methods: [SolMethod.SendAndConfirmTransaction],
+ scopes: [SolScope.Mainnet],
+ type: SolAccountType.DataAccount,
+ metadata: {
+ name: 'Solana Account',
+ keyring: { type: KeyringTypes.snap },
+ importTime: Date.now(),
+ lastSelected: Date.now(),
+ snap: {
+ id: 'mock-sol-snap',
+ },
+ },
+ },
+];
+
+const GROUP_ACCOUNTS_NO_SUPPORTED = [
+ createMockInternalAccount({
+ id: 'btc-account-id',
+ type: BtcAccountType.P2wpkh,
+ }),
+];
+
+type AllDeFiPositionsControllerV2Actions =
+ MessengerActions;
+
+type AllDeFiPositionsControllerV2Events =
+ MessengerEvents;
+
+type RootMessenger = Messenger<
+ MockAnyNamespace,
+ AllDeFiPositionsControllerV2Actions,
+ AllDeFiPositionsControllerV2Events
+>;
+
+/**
+ * Builds a minimal successful v6 balances response for the EVM account.
+ *
+ * @param overrides - Optional response overrides.
+ * @returns A v6 balances response.
+ */
+function buildMockBalancesResponse(
+ overrides?: Partial,
+): V6BalancesResponse {
+ return {
+ unprocessedNetworks: [],
+ unprocessedIncludeAssetIds: [],
+ accounts: [
+ {
+ accountId: `eip155:0:${EVM_ADDRESS}`,
+ balances: [
+ {
+ category: 'defi',
+ assetId:
+ 'eip155:1/erc20:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
+ name: 'Wrapped Ether',
+ symbol: 'WETH',
+ decimals: 18,
+ balance: '1',
+ price: '2000',
+ metadata: {
+ protocolId: 'aave-v3',
+ productName: 'Aave V3',
+ description: 'Aave V3 on ethereum',
+ protocolUrl: 'https://aave.com/',
+ protocolIconUrl: 'https://example.com/aave.png',
+ positionType: 'deposit',
+ poolAddress: '0xpool',
+ groupId: 'group-aave-1',
+ },
+ },
+ ],
+ },
+ ],
+ ...overrides,
+ };
+}
+
+/**
+ * Sets up the V2 controller with the given configuration.
+ *
+ * @param config - Configuration for the mock setup.
+ * @param config.isEnabled - Whether the controller is enabled.
+ * @param config.getVsCurrency - Fiat currency getter.
+ * @param config.mockGroupAccounts - Accounts returned for the selected group.
+ * @param config.getGroupAccounts - Getter for the selected group accounts
+ * (preferred when the selection changes between fetches).
+ * @param config.mockFetchV6MultiAccountBalances - Mock API fetch function.
+ * @param config.state - Initial controller state.
+ * @returns The controller instance and mocks.
+ */
+function setupController({
+ isEnabled = (): boolean => true,
+ getVsCurrency = (): string => 'USD',
+ mockGroupAccounts = GROUP_ACCOUNTS,
+ getGroupAccounts,
+ mockFetchV6MultiAccountBalances = jest
+ .fn()
+ .mockResolvedValue(buildMockBalancesResponse()),
+ state,
+}: {
+ isEnabled?: () => boolean;
+ getVsCurrency?: () => string;
+ mockGroupAccounts?: InternalAccount[];
+ getGroupAccounts?: () => InternalAccount[];
+ mockFetchV6MultiAccountBalances?: jest.Mock;
+ state?: Partial>;
+} = {}): {
+ controller: DeFiPositionsControllerV2;
+ controllerMessenger: Messenger<
+ 'DeFiPositionsControllerV2',
+ AllDeFiPositionsControllerV2Actions,
+ AllDeFiPositionsControllerV2Events,
+ RootMessenger
+ >;
+ mockFetchV6MultiAccountBalances: jest.Mock;
+} {
+ const messenger: RootMessenger = new Messenger({
+ namespace: MOCK_ANY_NAMESPACE,
+ });
+
+ messenger.registerActionHandler(
+ 'AccountTreeController:getAccountsFromSelectedAccountGroup',
+ () => getGroupAccounts?.() ?? mockGroupAccounts,
+ );
+
+ const controllerMessenger = new Messenger<
+ 'DeFiPositionsControllerV2',
+ AllDeFiPositionsControllerV2Actions,
+ AllDeFiPositionsControllerV2Events,
+ RootMessenger
+ >({
+ namespace: 'DeFiPositionsControllerV2',
+ parent: messenger,
+ });
+ messenger.delegate({
+ messenger: controllerMessenger,
+ actions: ['AccountTreeController:getAccountsFromSelectedAccountGroup'],
+ });
+
+ const apiClient = {
+ accounts: {
+ fetchV6MultiAccountBalances: mockFetchV6MultiAccountBalances,
+ },
+ } as unknown as ApiPlatformClient;
+
+ const controller = new DeFiPositionsControllerV2({
+ messenger: controllerMessenger,
+ apiClient,
+ isEnabled,
+ getVsCurrency,
+ state,
+ });
+
+ return {
+ controller,
+ controllerMessenger,
+ mockFetchV6MultiAccountBalances,
+ };
+}
+
+describe('DeFiPositionsControllerV2', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('sets default state', () => {
+ const { controller } = setupController();
+
+ expect(controller.state).toStrictEqual(
+ getDefaultDeFiPositionsControllerV2State(),
+ );
+ });
+
+ it('does not fetch when the controller is disabled', async () => {
+ const { controller, mockFetchV6MultiAccountBalances } = setupController({
+ isEnabled: () => false,
+ });
+
+ await controller.fetchDeFiPositions();
+
+ expect(mockFetchV6MultiAccountBalances).not.toHaveBeenCalled();
+ expect(controller.state).toStrictEqual(
+ getDefaultDeFiPositionsControllerV2State(),
+ );
+ });
+
+ it('does not fetch when the selected group has no supported accounts', async () => {
+ const { controller, mockFetchV6MultiAccountBalances } = setupController({
+ mockGroupAccounts: GROUP_ACCOUNTS_NO_SUPPORTED,
+ });
+
+ await controller.fetchDeFiPositions();
+
+ expect(mockFetchV6MultiAccountBalances).not.toHaveBeenCalled();
+ expect(controller.state).toStrictEqual(
+ getDefaultDeFiPositionsControllerV2State(),
+ );
+ });
+
+ it('fetches positions and stores them keyed by internal account ID', async () => {
+ const { controller, mockFetchV6MultiAccountBalances } = setupController();
+
+ await controller.fetchDeFiPositions();
+
+ expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(1);
+ expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledWith(
+ [`eip155:0:${EVM_ADDRESS.toLowerCase()}`],
+ {
+ networks: DEFI_SUPPORTED_NETWORKS.filter((network) =>
+ network.startsWith('eip155:'),
+ ),
+ includeDeFiBalances: true,
+ forceFetchDeFiPositions: true,
+ includePrices: true,
+ vsCurrency: 'usd',
+ },
+ {},
+ );
+
+ expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength(
+ 1,
+ );
+ expect(
+ controller.state.allDeFiPositionsV2['evm-account-id'][0],
+ ).toMatchObject({
+ protocolId: 'aave-v3',
+ productName: 'Aave V3',
+ chainId: 'eip155:1',
+ marketValue: 2000,
+ });
+ });
+
+ it('maps mixed-case EVM response account IDs back to internal IDs', async () => {
+ const mockFetchV6MultiAccountBalances = jest.fn().mockResolvedValue(
+ buildMockBalancesResponse({
+ accounts: [
+ {
+ accountId: `eip155:0:${EVM_ADDRESS.toUpperCase()}`,
+ balances: [
+ {
+ category: 'defi',
+ assetId:
+ 'eip155:1/erc20:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
+ name: 'Wrapped Ether',
+ symbol: 'WETH',
+ decimals: 18,
+ balance: '1',
+ price: '2000',
+ metadata: {
+ protocolId: 'aave-v3',
+ productName: 'Aave V3',
+ description: 'Aave V3 on ethereum',
+ protocolUrl: 'https://aave.com/',
+ protocolIconUrl: 'https://example.com/aave.png',
+ positionType: 'deposit',
+ poolAddress: '0xpool',
+ groupId: 'group-aave-1',
+ },
+ },
+ ],
+ },
+ ],
+ }),
+ );
+
+ const { controller } = setupController({
+ mockFetchV6MultiAccountBalances,
+ });
+
+ await controller.fetchDeFiPositions();
+
+ expect(controller.state.allDeFiPositionsV2).toHaveProperty(
+ 'evm-account-id',
+ );
+ expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength(
+ 1,
+ );
+ });
+
+ it('requests Solana and EVM networks when both accounts are present', async () => {
+ const mockFetchV6MultiAccountBalances = jest.fn().mockResolvedValue(
+ buildMockBalancesResponse({
+ accounts: [
+ {
+ accountId: `eip155:0:${EVM_ADDRESS}`,
+ balances: [],
+ },
+ {
+ accountId: `solana:${SolScope.Mainnet.split(':')[1]}:${SOLANA_ADDRESS}`,
+ balances: [],
+ },
+ ],
+ }),
+ );
+
+ const { controller, mockFetchV6MultiAccountBalances: mockFetch } =
+ setupController({
+ mockGroupAccounts: GROUP_ACCOUNTS_WITH_SOLANA,
+ mockFetchV6MultiAccountBalances,
+ });
+
+ await controller.fetchDeFiPositions();
+
+ const expectedEvmNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) =>
+ network.startsWith('eip155:'),
+ );
+ const expectedSolanaNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) =>
+ network.startsWith('solana:'),
+ );
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ [
+ `eip155:0:${EVM_ADDRESS.toLowerCase()}`,
+ `solana:${SolScope.Mainnet.split(':')[1]}:${SOLANA_ADDRESS}`,
+ ],
+ {
+ networks: [...expectedEvmNetworks, ...expectedSolanaNetworks],
+ includeDeFiBalances: true,
+ forceFetchDeFiPositions: true,
+ includePrices: true,
+ vsCurrency: 'usd',
+ },
+ {},
+ );
+ expect(controller.state.allDeFiPositionsV2).toStrictEqual({
+ 'evm-account-id': [],
+ 'solana-account-id': [],
+ });
+ });
+
+ it('keeps prior state for accounts still indexing DeFi positions', async () => {
+ const { controller, mockFetchV6MultiAccountBalances } = setupController({
+ mockFetchV6MultiAccountBalances: jest
+ .fn()
+ .mockResolvedValueOnce(buildMockBalancesResponse())
+ .mockResolvedValueOnce(
+ buildMockBalancesResponse({
+ accounts: [
+ {
+ accountId: `eip155:0:${EVM_ADDRESS}`,
+ processingDefiPositions: true,
+ balances: [],
+ },
+ ],
+ }),
+ ),
+ });
+
+ await controller.fetchDeFiPositions();
+ const cached = controller.state.allDeFiPositionsV2['evm-account-id'];
+ expect(cached).toHaveLength(1);
+
+ await controller.fetchDeFiPositions({ forceRefresh: true });
+ expect(controller.state.allDeFiPositionsV2['evm-account-id']).toBe(cached);
+ expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2);
+ });
+
+ it('updates ready accounts while skipping ones still indexing', async () => {
+ const solanaAccountId = `solana:${SolScope.Mainnet.split(':')[1]}:${SOLANA_ADDRESS}`;
+ const { controller } = setupController({
+ mockGroupAccounts: GROUP_ACCOUNTS_WITH_SOLANA,
+ mockFetchV6MultiAccountBalances: jest
+ .fn()
+ .mockResolvedValueOnce(
+ buildMockBalancesResponse({
+ accounts: [
+ {
+ accountId: `eip155:0:${EVM_ADDRESS}`,
+ balances: buildMockBalancesResponse().accounts[0].balances,
+ },
+ {
+ accountId: solanaAccountId,
+ balances: [
+ {
+ category: 'defi',
+ assetId: `${SolScope.Mainnet}/token:${SOLANA_ADDRESS}`,
+ name: 'Wrapped SOL',
+ symbol: 'WSOL',
+ decimals: 9,
+ balance: '1',
+ price: '100',
+ metadata: {
+ protocolId: 'marinade',
+ productName: 'Marinade',
+ description: 'Marinade on solana',
+ protocolUrl: 'https://marinade.finance/',
+ protocolIconUrl: 'https://example.com/marinade.png',
+ positionType: 'stake',
+ poolAddress: 'pool',
+ groupId: 'group-marinade-1',
+ },
+ },
+ ],
+ },
+ ],
+ }),
+ )
+ .mockResolvedValueOnce(
+ buildMockBalancesResponse({
+ accounts: [
+ {
+ accountId: `eip155:0:${EVM_ADDRESS}`,
+ processingDefiPositions: true,
+ balances: [],
+ },
+ {
+ accountId: solanaAccountId,
+ balances: [],
+ },
+ ],
+ }),
+ ),
+ });
+
+ await controller.fetchDeFiPositions();
+ const evmPositions = controller.state.allDeFiPositionsV2['evm-account-id'];
+ expect(evmPositions).toHaveLength(1);
+ expect(
+ controller.state.allDeFiPositionsV2['solana-account-id'],
+ ).toHaveLength(1);
+
+ await controller.fetchDeFiPositions({ forceRefresh: true });
+
+ // Still-indexing EVM account keeps prior positions; ready Solana clears.
+ expect(controller.state.allDeFiPositionsV2['evm-account-id']).toBe(
+ evmPositions,
+ );
+ expect(
+ controller.state.allDeFiPositionsV2['solana-account-id'],
+ ).toStrictEqual([]);
+ });
+
+ it('merges fetched accounts into state without clearing other accounts', async () => {
+ const otherEvmAddress = '0x0000000000000000000000000000000000000002';
+ const otherEvmAccount = createMockInternalAccount({
+ id: 'evm-account-id-2',
+ address: otherEvmAddress,
+ type: EthAccountType.Eoa,
+ });
+ let groupAccounts: InternalAccount[] = GROUP_ACCOUNTS;
+ const { controller } = setupController({
+ getGroupAccounts: () => groupAccounts,
+ mockFetchV6MultiAccountBalances: jest
+ .fn()
+ .mockResolvedValueOnce(buildMockBalancesResponse())
+ .mockResolvedValueOnce(
+ buildMockBalancesResponse({
+ accounts: [
+ {
+ accountId: `eip155:0:${otherEvmAddress}`,
+ balances: [],
+ },
+ ],
+ }),
+ ),
+ });
+
+ await controller.fetchDeFiPositions();
+ expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength(
+ 1,
+ );
+
+ groupAccounts = [otherEvmAccount];
+ await controller.fetchDeFiPositions();
+
+ expect(controller.state.allDeFiPositionsV2).toStrictEqual({
+ 'evm-account-id': expect.any(Array),
+ 'evm-account-id-2': [],
+ });
+ expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength(
+ 1,
+ );
+ });
+
+ it('passes staleTime: 0 to the apiClient when forceRefresh is true', async () => {
+ const { controller, mockFetchV6MultiAccountBalances } = setupController();
+
+ await controller.fetchDeFiPositions({ forceRefresh: true });
+
+ expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledWith(
+ expect.any(Array),
+ expect.objectContaining({ vsCurrency: 'usd' }),
+ { staleTime: 0 },
+ );
+ });
+
+ it('passes the current vsCurrency to the apiClient', async () => {
+ let vsCurrency = 'USD';
+ const { controller, mockFetchV6MultiAccountBalances } = setupController({
+ getVsCurrency: () => vsCurrency,
+ });
+
+ await controller.fetchDeFiPositions();
+ expect(mockFetchV6MultiAccountBalances).toHaveBeenLastCalledWith(
+ expect.any(Array),
+ expect.objectContaining({ vsCurrency: 'usd' }),
+ {},
+ );
+
+ vsCurrency = 'EUR';
+ await controller.fetchDeFiPositions();
+
+ expect(mockFetchV6MultiAccountBalances).toHaveBeenLastCalledWith(
+ expect.any(Array),
+ expect.objectContaining({ vsCurrency: 'eur' }),
+ {},
+ );
+ });
+
+ it('keeps prior state when a fetch fails', async () => {
+ const consoleErrorSpy = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined);
+ const mockFetchV6MultiAccountBalances = jest
+ .fn()
+ .mockResolvedValueOnce(buildMockBalancesResponse())
+ .mockRejectedValueOnce(new Error('network error'));
+
+ const { controller } = setupController({
+ mockFetchV6MultiAccountBalances,
+ });
+
+ await controller.fetchDeFiPositions();
+ expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength(
+ 1,
+ );
+
+ await controller.fetchDeFiPositions();
+ expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2);
+ expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength(
+ 1,
+ );
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to fetch DeFi positions',
+ expect.any(Error),
+ );
+ });
+
+ it('exposes fetchDeFiPositions via the messenger', async () => {
+ const { controllerMessenger, mockFetchV6MultiAccountBalances } =
+ setupController();
+
+ await controllerMessenger.call(
+ 'DeFiPositionsControllerV2:fetchDeFiPositions',
+ );
+
+ expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(1);
+ });
+
+ describe('metadata', () => {
+ it('includes expected state in debug snapshots', () => {
+ const { controller } = setupController();
+
+ expect(
+ deriveStateFromMetadata(
+ controller.state,
+ controller.metadata,
+ 'includeInDebugSnapshot',
+ ),
+ ).toMatchInlineSnapshot(`{}`);
+ });
+
+ it('includes expected state in state logs', () => {
+ const { controller } = setupController();
+
+ expect(
+ deriveStateFromMetadata(
+ controller.state,
+ controller.metadata,
+ 'includeInStateLogs',
+ ),
+ ).toMatchInlineSnapshot(`{}`);
+ });
+
+ it('persists expected state', () => {
+ const { controller } = setupController();
+
+ expect(
+ deriveStateFromMetadata(
+ controller.state,
+ controller.metadata,
+ 'persist',
+ ),
+ ).toMatchInlineSnapshot(`
+ {
+ "allDeFiPositionsV2": {},
+ }
+ `);
+ });
+
+ it('exposes expected state to UI', () => {
+ const { controller } = setupController();
+
+ expect(
+ deriveStateFromMetadata(
+ controller.state,
+ controller.metadata,
+ 'usedInUi',
+ ),
+ ).toMatchInlineSnapshot(`
+ {
+ "allDeFiPositionsV2": {},
+ }
+ `);
+ });
+ });
+});
diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts
new file mode 100644
index 00000000000..2030e6e0075
--- /dev/null
+++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts
@@ -0,0 +1,232 @@
+import type { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction } from '@metamask/account-tree-controller';
+import { BaseController } from '@metamask/base-controller';
+import type {
+ ControllerGetStateAction,
+ ControllerStateChangedEvent,
+ StateMetadata,
+} from '@metamask/base-controller';
+import type { ApiPlatformClient } from '@metamask/core-backend';
+import type { Messenger } from '@metamask/messenger';
+
+import { buildDeFiBalancesQuery } from './build-defi-balances-query';
+import type { DeFiPositionsControllerV2MethodActions } from './DeFiPositionsControllerV2-method-action-types';
+import type { DeFiPositionsByAccount } from './group-defi-positions-v6';
+import { groupDeFiPositionsV6 } from './group-defi-positions-v6';
+
+const controllerName = 'DeFiPositionsControllerV2';
+
+const MESSENGER_EXPOSED_METHODS = ['fetchDeFiPositions'] as const;
+
+export type DeFiPositionsControllerV2State = {
+ /**
+ * DeFi positions keyed by internal MetaMask account ID (`InternalAccount.id`,
+ * the same key AssetsController uses). Each account maps to a flat list of
+ * protocol groups shown in the DeFi tab, each carrying its own `chainId` for
+ * filtering plus the details-page sections embedded inside it. This is
+ * exactly the shape the client consumes, so no further transformation is
+ * needed on read.
+ *
+ * Named `allDeFiPositionsV2` (rather than `allDeFiPositions`) so it can live
+ * alongside the legacy `DeFiPositionsController` in clients that flatten every
+ * controller's state into a single object (e.g. the extension background),
+ * without colliding on the shared `allDeFiPositions` key.
+ */
+ allDeFiPositionsV2: DeFiPositionsByAccount;
+};
+
+const controllerMetadata: StateMetadata = {
+ allDeFiPositionsV2: {
+ includeInStateLogs: false,
+ persist: true,
+ includeInDebugSnapshot: false,
+ usedInUi: true,
+ },
+};
+
+export const getDefaultDeFiPositionsControllerV2State =
+ (): DeFiPositionsControllerV2State => {
+ return {
+ allDeFiPositionsV2: {},
+ };
+ };
+
+export type DeFiPositionsControllerV2GetStateAction = ControllerGetStateAction<
+ typeof controllerName,
+ DeFiPositionsControllerV2State
+>;
+
+export type DeFiPositionsControllerV2Actions =
+ | DeFiPositionsControllerV2GetStateAction
+ | DeFiPositionsControllerV2MethodActions;
+
+export type DeFiPositionsControllerV2StateChangedEvent =
+ ControllerStateChangedEvent<
+ typeof controllerName,
+ DeFiPositionsControllerV2State
+ >;
+
+export type DeFiPositionsControllerV2Events =
+ DeFiPositionsControllerV2StateChangedEvent;
+
+/**
+ * The external actions available to the {@link DeFiPositionsControllerV2}.
+ */
+export type AllowedActions =
+ AccountTreeControllerGetAccountsFromSelectedAccountGroupAction;
+
+/**
+ * The external events available to the {@link DeFiPositionsControllerV2}.
+ *
+ * None yet — clients must call `fetchDeFiPositions` (and optionally
+ * `{ forceRefresh: true }`) on their own triggers. Likely future subscriptions:
+ * `AccountTreeController:selectedAccountGroupChange`,
+ * `TransactionController:transactionConfirmed`, and `KeyringController:lock`.
+ */
+export type AllowedEvents = never;
+
+export type DeFiPositionsControllerV2Messenger = Messenger<
+ typeof controllerName,
+ DeFiPositionsControllerV2Actions | AllowedActions,
+ DeFiPositionsControllerV2Events | AllowedEvents
+>;
+
+/**
+ * Controller that fetches DeFi positions for the selected account group from
+ * the Accounts API (v6 multiaccount balances) and stores them in the shape the
+ * client consumes directly.
+ *
+ * Deduplication and freshness are handled by the shared TanStack Query cache on
+ * {@link ApiPlatformClient} (balances default `staleTime` is 1 minute). Pass
+ * `{ forceRefresh: true }` to bypass that cache for pull-to-refresh.
+ */
+export class DeFiPositionsControllerV2 extends BaseController<
+ typeof controllerName,
+ DeFiPositionsControllerV2State,
+ DeFiPositionsControllerV2Messenger
+> {
+ readonly #apiClient: ApiPlatformClient;
+
+ readonly #isEnabled: () => boolean;
+
+ readonly #getVsCurrency: () => string;
+
+ /**
+ * @param options - Constructor options.
+ * @param options.messenger - The controller messenger.
+ * @param options.apiClient - Accounts API client used to fetch balances/positions. Auth is handled by the client.
+ * @param options.isEnabled - Returns whether fetching is enabled (default: () => false).
+ * @param options.getVsCurrency - Returns the fiat currency for prices (default: () => 'usd').
+ * @param options.state - Initial controller state.
+ */
+ constructor({
+ messenger,
+ apiClient,
+ isEnabled,
+ getVsCurrency,
+ state,
+ }: {
+ messenger: DeFiPositionsControllerV2Messenger;
+ apiClient: ApiPlatformClient;
+ isEnabled: () => boolean;
+ getVsCurrency: () => string;
+ state?: Partial;
+ }) {
+ super({
+ name: controllerName,
+ metadata: controllerMetadata,
+ messenger,
+ state: {
+ ...getDefaultDeFiPositionsControllerV2State(),
+ ...state,
+ },
+ });
+
+ this.#apiClient = apiClient;
+ this.#isEnabled = isEnabled;
+ this.#getVsCurrency = getVsCurrency;
+
+ this.messenger.registerMethodActionHandlers(
+ this,
+ MESSENGER_EXPOSED_METHODS,
+ );
+ }
+
+ /**
+ * Fetches DeFi positions for the selected account group. Each account key in
+ * a ready response replaces that account's state (other accounts stay).
+ * Accounts still indexing (`processingDefiPositions`) are skipped so prior
+ * state is kept for them. No-ops when disabled or when the group has no
+ * supported accounts. Caching / spam prevention is handled by the apiClient
+ * TanStack Query cache (keyed by accounts + query options including
+ * `vsCurrency`). Pass `{ forceRefresh: true }` to bypass the cache (e.g.
+ * pull-to-refresh).
+ *
+ * @param options - Optional fetch modifiers.
+ * @param options.forceRefresh - When true, bypass the apiClient cache and
+ * fetch immediately.
+ */
+ async fetchDeFiPositions(options?: {
+ forceRefresh?: boolean;
+ }): Promise {
+ if (!this.#isEnabled()) {
+ return;
+ }
+
+ const selectedAccounts = this.messenger.call(
+ 'AccountTreeController:getAccountsFromSelectedAccountGroup',
+ );
+
+ const { networks, internalAccountIdByCaip } =
+ buildDeFiBalancesQuery(selectedAccounts);
+
+ if (internalAccountIdByCaip.size === 0 || networks.length === 0) {
+ return;
+ }
+
+ const accountIds = [...internalAccountIdByCaip.keys()];
+ const vsCurrency = this.#getVsCurrency().toLowerCase();
+
+ try {
+ const response =
+ await this.#apiClient.accounts.fetchV6MultiAccountBalances(
+ accountIds,
+ {
+ networks,
+ includeDeFiBalances: true,
+ forceFetchDeFiPositions: true,
+ includePrices: true,
+ vsCurrency,
+ },
+ {
+ // staleTime: 0 makes TanStack treat the cache as stale for this call.
+ ...(options?.forceRefresh ? { staleTime: 0 } : {}),
+ },
+ );
+
+ // Skip accounts still indexing — their balances are not a valid snapshot.
+ const readyAccounts = response.accounts.filter(
+ (account) => !account.processingDefiPositions,
+ );
+ if (readyAccounts.length === 0) {
+ return;
+ }
+
+ const positionsByAccount = groupDeFiPositionsV6(
+ { ...response, accounts: readyAccounts },
+ internalAccountIdByCaip,
+ );
+
+ // Last valid response wins per ready account; processing / other accounts
+ // stay untouched.
+ this.update((state) => {
+ for (const [accountId, positions] of Object.entries(
+ positionsByAccount,
+ )) {
+ state.allDeFiPositionsV2[accountId] = positions;
+ }
+ });
+ } catch (error) {
+ console.error('Failed to fetch DeFi positions', error);
+ }
+ }
+}
diff --git a/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.test.ts b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.test.ts
new file mode 100644
index 00000000000..7446bfbeb94
--- /dev/null
+++ b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.test.ts
@@ -0,0 +1,164 @@
+import {
+ BtcAccountType,
+ EthAccountType,
+ EthMethod,
+ EthScope,
+ SolAccountType,
+ SolMethod,
+ SolScope,
+} from '@metamask/keyring-api';
+import { KeyringTypes } from '@metamask/keyring-controller';
+import type { InternalAccount } from '@metamask/keyring-internal-api';
+
+import { createMockInternalAccount } from '../../../accounts-controller/tests/mocks';
+import {
+ buildDeFiBalancesQuery,
+ DEFI_SUPPORTED_NETWORKS,
+} from './build-defi-balances-query';
+
+const EVM_ADDRESS = '0x0000000000000000000000000000000000000001';
+const SOLANA_ADDRESS = 'So11111111111111111111111111111111111111112';
+
+const mockEvmAccount = createMockInternalAccount({
+ id: 'evm-account-id',
+ address: EVM_ADDRESS,
+ type: EthAccountType.Eoa,
+});
+
+const mockSolanaAccount: InternalAccount = {
+ id: 'solana-account-id',
+ address: SOLANA_ADDRESS,
+ options: {},
+ methods: [SolMethod.SendAndConfirmTransaction],
+ scopes: [SolScope.Mainnet],
+ type: SolAccountType.DataAccount,
+ metadata: {
+ name: 'Solana Account',
+ keyring: { type: KeyringTypes.snap },
+ importTime: Date.now(),
+ lastSelected: Date.now(),
+ snap: {
+ id: 'mock-sol-snap',
+ name: 'mock-sol-snap',
+ enabled: true,
+ },
+ },
+};
+
+const mockBtcAccount = createMockInternalAccount({
+ id: 'btc-account-id',
+ type: BtcAccountType.P2wpkh,
+});
+
+describe('buildDeFiBalancesQuery', () => {
+ it('builds an EVM CAIP account spanning all supported EVM networks', () => {
+ const mixedCaseEvmAccount = createMockInternalAccount({
+ id: 'evm-account-id',
+ address: EVM_ADDRESS.toUpperCase(),
+ type: EthAccountType.Eoa,
+ });
+ const result = buildDeFiBalancesQuery([
+ mixedCaseEvmAccount,
+ mockBtcAccount,
+ ]);
+
+ const expectedEvmNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) =>
+ network.startsWith('eip155:'),
+ );
+
+ expect(result.networks).toStrictEqual(expectedEvmNetworks);
+ expect([...result.internalAccountIdByCaip.entries()]).toStrictEqual([
+ [`eip155:0:${EVM_ADDRESS.toLowerCase()}`, 'evm-account-id'],
+ ]);
+ });
+
+ it('builds a Solana CAIP account for supported Solana networks', () => {
+ const result = buildDeFiBalancesQuery([mockSolanaAccount, mockBtcAccount]);
+
+ const expectedSolanaNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) =>
+ network.startsWith('solana:'),
+ );
+ const [, solanaReference] = SolScope.Mainnet.split(':');
+
+ expect(result.networks).toStrictEqual(expectedSolanaNetworks);
+ expect([...result.internalAccountIdByCaip.entries()]).toStrictEqual([
+ [`solana:${solanaReference}:${SOLANA_ADDRESS}`, 'solana-account-id'],
+ ]);
+ });
+
+ it('combines EVM and Solana accounts from the selected group', () => {
+ const result = buildDeFiBalancesQuery([
+ mockEvmAccount,
+ mockSolanaAccount,
+ mockBtcAccount,
+ ]);
+
+ const expectedEvmNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) =>
+ network.startsWith('eip155:'),
+ );
+ const expectedSolanaNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) =>
+ network.startsWith('solana:'),
+ );
+
+ // EVM networks are added first, then Solana — not the literal
+ // DEFI_SUPPORTED_NETWORKS order (where Solana sits mid-list).
+ expect(result.networks).toStrictEqual([
+ ...expectedEvmNetworks,
+ ...expectedSolanaNetworks,
+ ]);
+ expect(result.internalAccountIdByCaip.size).toBe(2);
+ expect(
+ result.internalAccountIdByCaip.get(
+ `eip155:0:${EVM_ADDRESS.toLowerCase()}`,
+ ),
+ ).toBe('evm-account-id');
+ expect(
+ result.internalAccountIdByCaip.get(
+ `solana:${SolScope.Mainnet.split(':')[1]}:${SOLANA_ADDRESS}`,
+ ),
+ ).toBe('solana-account-id');
+ });
+
+ it('returns empty networks and map when there are no supported accounts', () => {
+ const result = buildDeFiBalancesQuery([mockBtcAccount]);
+
+ expect(result).toStrictEqual({
+ networks: [],
+ internalAccountIdByCaip: new Map(),
+ });
+ });
+
+ it('uses only the first EVM and first Solana account in the group', () => {
+ const secondEvmAccount = createMockInternalAccount({
+ id: 'evm-account-id-2',
+ address: '0x0000000000000000000000000000000000000002',
+ type: EthAccountType.Eoa,
+ methods: [EthMethod.SignTransaction],
+ scopes: [EthScope.Eoa],
+ });
+ const secondSolanaAccount: InternalAccount = {
+ ...mockSolanaAccount,
+ id: 'solana-account-id-2',
+ address: 'So22222222222222222222222222222222222222222',
+ };
+
+ const result = buildDeFiBalancesQuery([
+ mockEvmAccount,
+ secondEvmAccount,
+ mockSolanaAccount,
+ secondSolanaAccount,
+ ]);
+
+ expect(result.internalAccountIdByCaip.size).toBe(2);
+ expect(
+ result.internalAccountIdByCaip.get(
+ `eip155:0:${EVM_ADDRESS.toLowerCase()}`,
+ ),
+ ).toBe('evm-account-id');
+ expect(
+ result.internalAccountIdByCaip.get(
+ `solana:${SolScope.Mainnet.split(':')[1]}:${SOLANA_ADDRESS}`,
+ ),
+ ).toBe('solana-account-id');
+ });
+});
diff --git a/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts
new file mode 100644
index 00000000000..cde9b786125
--- /dev/null
+++ b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts
@@ -0,0 +1,113 @@
+import {
+ isEvmAccountType,
+ SolAccountType,
+ SolScope,
+} from '@metamask/keyring-api';
+import type { InternalAccount } from '@metamask/keyring-internal-api';
+import type { CaipAccountId, CaipChainId } from '@metamask/utils';
+import { KnownCaipNamespace, toCaipAccountId } from '@metamask/utils';
+
+/**
+ * Networks the DeFi balances (v6 multiaccount) endpoint supports.
+ * Cross-section of the supported chains from:
+ * https://developers.zerion.io/supported-blockchains
+ * https://accounts.api.cx.metamask.io/v2/supportedNetworks
+ */
+export const DEFI_SUPPORTED_NETWORKS: readonly CaipChainId[] = [
+ 'eip155:1',
+ 'eip155:137',
+ 'eip155:56',
+ 'eip155:1329',
+ 'eip155:43114',
+ 'eip155:59144',
+ 'eip155:8453',
+ 'eip155:10',
+ 'eip155:42161',
+ 'eip155:143',
+ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
+ 'eip155:999',
+ 'eip155:5042',
+];
+
+const SOLANA_MAINNET_CAIP_CHAIN_ID: CaipChainId = SolScope.Mainnet;
+
+export type DeFiBalancesQuery = {
+ /** CAIP-2 networks to query, deduped across accounts. */
+ networks: CaipChainId[];
+ /**
+ * Request CAIP-10 account IDs → internal MetaMask account IDs
+ * (`InternalAccount.id`). EVM keys use the all-chains reference and a
+ * lowercased address; Solana keys keep address case.
+ */
+ internalAccountIdByCaip: Map;
+};
+
+/**
+ * Builds an EVM CAIP-10 account ID that spans every EVM chain (reference `0`).
+ * Addresses are lowercased because EVM addresses are case-insensitive.
+ *
+ * @param address - The EVM account address.
+ * @returns The CAIP-10 account ID for the address.
+ */
+function toEvmCaipAccountId(address: string): CaipAccountId {
+ return toCaipAccountId(KnownCaipNamespace.Eip155, '0', address.toLowerCase());
+}
+
+/**
+ * Builds the account IDs and networks to request DeFi positions for, from the
+ * accounts in the selected account group.
+ *
+ * Picks the group's EVM account (queried across all supported EVM chains) and
+ * its Solana account (queried on supported Solana chains). Enabled-network
+ * filtering is intentionally omitted here: positions are stored per chain, so
+ * the client can filter by enabled networks when reading state.
+ *
+ * @param internalAccounts - Accounts belonging to the selected account group.
+ * @returns Networks and a CAIP→internal account ID map for the v6 multiaccount
+ * balances request. Map keys are the CAIP account IDs to query.
+ */
+export function buildDeFiBalancesQuery(
+ internalAccounts: InternalAccount[],
+): DeFiBalancesQuery {
+ const evmNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) =>
+ network.startsWith(`${KnownCaipNamespace.Eip155}:`),
+ );
+ const solanaNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) =>
+ network.startsWith(`${KnownCaipNamespace.Solana}:`),
+ );
+
+ const networks: CaipChainId[] = [];
+ const internalAccountIdByCaip = new Map();
+
+ const evmAccount = internalAccounts.find((account) =>
+ isEvmAccountType(account.type),
+ );
+ if (evmAccount && evmNetworks.length > 0) {
+ internalAccountIdByCaip.set(
+ toEvmCaipAccountId(evmAccount.address),
+ evmAccount.id,
+ );
+ networks.push(...evmNetworks);
+ }
+
+ const solanaAccount = internalAccounts.find(
+ (account) => account.type === SolAccountType.DataAccount,
+ );
+ if (solanaAccount && solanaNetworks.length > 0) {
+ const [, solanaReference] = SOLANA_MAINNET_CAIP_CHAIN_ID.split(':');
+ internalAccountIdByCaip.set(
+ toCaipAccountId(
+ KnownCaipNamespace.Solana,
+ solanaReference,
+ solanaAccount.address,
+ ),
+ solanaAccount.id,
+ );
+ networks.push(...solanaNetworks);
+ }
+
+ return {
+ networks: [...new Set(networks)] as CaipChainId[],
+ internalAccountIdByCaip,
+ };
+}
diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.test.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.test.ts
new file mode 100644
index 00000000000..59f3d6c8e8c
--- /dev/null
+++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.test.ts
@@ -0,0 +1,512 @@
+import type { V6BalancesResponse } from '@metamask/core-backend';
+import type { CaipAssetType } from '@metamask/utils';
+
+import { groupDeFiPositionsV6 } from './group-defi-positions-v6';
+
+const AAVE_METADATA = {
+ protocolId: 'aave-v3',
+ productName: 'Aave V3',
+ description: 'Aave V3 on ethereum',
+ protocolUrl: 'https://aave.com/',
+ protocolIconUrl: 'https://example.com/aave.png',
+ positionType: 'deposit',
+ poolAddress: '0xpool',
+ groupId: 'group-aave-1',
+};
+
+const WETH_ASSET_ID =
+ 'eip155:1/erc20:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' as CaipAssetType;
+const USDC_ASSET_ID =
+ 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as CaipAssetType;
+const USDT_ASSET_ID =
+ 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7' as CaipAssetType;
+const BASE_WETH_ASSET_ID =
+ 'eip155:8453/erc20:0x4200000000000000000000000000000000000006' as CaipAssetType;
+
+/**
+ * Builds a minimal v6 balances response for tests.
+ *
+ * @param accounts - Account entries to include.
+ * @returns A v6 balances response.
+ */
+function buildResponse(
+ accounts: V6BalancesResponse['accounts'],
+): V6BalancesResponse {
+ return {
+ unprocessedNetworks: [],
+ unprocessedIncludeAssetIds: [],
+ accounts,
+ };
+}
+
+describe('groupDeFiPositionsV6', () => {
+ it('groups DeFi positions by chain and protocolId', () => {
+ const response = buildResponse([
+ {
+ accountId: 'eip155:0:0xabc',
+ balances: [
+ {
+ category: 'defi',
+ assetId: WETH_ASSET_ID,
+ name: 'Wrapped Ether',
+ symbol: 'WETH',
+ decimals: 18,
+ balance: '1',
+ price: '2000',
+ metadata: AAVE_METADATA,
+ },
+ {
+ category: 'defi',
+ assetId: USDC_ASSET_ID,
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ balance: '100',
+ price: '1',
+ metadata: {
+ ...AAVE_METADATA,
+ positionType: 'deposit',
+ poolAddress: '0xpool2',
+ },
+ },
+ {
+ category: 'defi',
+ assetId: BASE_WETH_ASSET_ID,
+ name: 'Wrapped Ether',
+ symbol: 'WETH',
+ decimals: 18,
+ balance: '2',
+ price: '2000',
+ metadata: AAVE_METADATA,
+ },
+ ],
+ },
+ ]);
+
+ const result = groupDeFiPositionsV6(response);
+
+ expect(Object.keys(result)).toStrictEqual(['eip155:0:0xabc']);
+ expect(result['eip155:0:0xabc']).toHaveLength(2);
+
+ const ethGroup = result['eip155:0:0xabc'].find(
+ (group) => group.chainId === 'eip155:1',
+ );
+ const baseGroup = result['eip155:0:0xabc'].find(
+ (group) => group.chainId === 'eip155:8453',
+ );
+
+ expect(ethGroup).toMatchObject({
+ protocolId: 'aave-v3',
+ productName: 'Aave V3',
+ protocolIconUrl: 'https://example.com/aave.png',
+ chainId: 'eip155:1',
+ marketValue: 2100,
+ });
+ expect(ethGroup?.sections).toHaveLength(1);
+ expect(ethGroup?.sections[0].positions).toHaveLength(2);
+
+ expect(baseGroup).toMatchObject({
+ protocolId: 'aave-v3',
+ chainId: 'eip155:8453',
+ marketValue: 4000,
+ });
+ });
+
+ it('ignores token rows and defi rows without protocol metadata', () => {
+ const response = buildResponse([
+ {
+ accountId: 'account-1',
+ balances: [
+ {
+ category: 'token',
+ assetId: WETH_ASSET_ID,
+ name: 'Wrapped Ether',
+ symbol: 'WETH',
+ decimals: 18,
+ balance: '1',
+ price: '2000',
+ },
+ {
+ category: 'defi',
+ assetId: USDC_ASSET_ID,
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ balance: '100',
+ price: '1',
+ },
+ {
+ category: 'defi',
+ assetId: USDT_ASSET_ID,
+ name: 'Tether',
+ symbol: 'USDT',
+ decimals: 6,
+ balance: '50',
+ price: '1',
+ metadata: {
+ limit: '1',
+ },
+ },
+ ],
+ },
+ ]);
+
+ const result = groupDeFiPositionsV6(response);
+
+ expect(result['account-1']).toStrictEqual([]);
+ });
+
+ it('seeds an empty list for accounts with no DeFi positions', () => {
+ const response = buildResponse([
+ {
+ accountId: 'account-empty',
+ balances: [],
+ },
+ ]);
+
+ expect(groupDeFiPositionsV6(response)).toStrictEqual({
+ 'account-empty': [],
+ });
+ });
+
+ it('maps response accounts to internal IDs and skips unmatched ones', () => {
+ const response = buildResponse([
+ {
+ accountId: 'eip155:1:0xUnknown',
+ balances: [
+ {
+ category: 'defi',
+ assetId: WETH_ASSET_ID,
+ name: 'Wrapped Ether',
+ symbol: 'WETH',
+ decimals: 18,
+ balance: '1',
+ price: '2000',
+ metadata: AAVE_METADATA,
+ },
+ ],
+ },
+ {
+ // Response uses a per-chain reference + mixed case; request map uses
+ // the all-chains reference (`eip155:0:...`).
+ accountId: 'eip155:1:0xKnown',
+ balances: [
+ {
+ category: 'defi',
+ assetId: USDC_ASSET_ID,
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ balance: '10',
+ price: '1',
+ metadata: AAVE_METADATA,
+ },
+ ],
+ },
+ ]);
+
+ const result = groupDeFiPositionsV6(
+ response,
+ new Map([['eip155:0:0xknown', 'internal-1']]),
+ );
+
+ expect(Object.keys(result)).toStrictEqual(['internal-1']);
+ expect(result['internal-1']).toHaveLength(1);
+ });
+
+ it('omits market value when price is missing or invalid', () => {
+ const response = buildResponse([
+ {
+ accountId: 'account-1',
+ balances: [
+ {
+ category: 'defi',
+ assetId: WETH_ASSET_ID,
+ name: 'Wrapped Ether',
+ symbol: 'WETH',
+ decimals: 18,
+ balance: '1',
+ metadata: AAVE_METADATA,
+ },
+ {
+ category: 'defi',
+ assetId: USDC_ASSET_ID,
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ balance: 'not-a-number',
+ price: '1',
+ metadata: {
+ ...AAVE_METADATA,
+ productName: 'Aave V3 USDC',
+ },
+ },
+ {
+ category: 'defi',
+ assetId: USDT_ASSET_ID,
+ name: 'Tether',
+ symbol: 'USDT',
+ decimals: 6,
+ balance: '5',
+ price: '1',
+ metadata: {
+ ...AAVE_METADATA,
+ productName: 'Aave V3 USDT',
+ },
+ },
+ ],
+ },
+ ]);
+
+ const [group] = groupDeFiPositionsV6(response)['account-1'];
+
+ expect(group.marketValue).toBe(5);
+ expect(group.sections[0].positions[0].marketValue).toBeUndefined();
+ expect(group.sections[1].positions[0].marketValue).toBeUndefined();
+ expect(group.sections[2].positions[0].marketValue).toBe(5);
+ });
+
+ it('subtracts lending positions from the protocol market value', () => {
+ const response = buildResponse([
+ {
+ accountId: 'account-1',
+ balances: [
+ {
+ category: 'defi',
+ assetId: WETH_ASSET_ID,
+ name: 'Wrapped Ether',
+ symbol: 'WETH',
+ decimals: 18,
+ balance: '1',
+ price: '2000',
+ metadata: {
+ ...AAVE_METADATA,
+ productName: 'Aave V3 Supply',
+ positionType: 'deposit',
+ },
+ },
+ {
+ category: 'defi',
+ assetId: USDC_ASSET_ID,
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ balance: '500',
+ price: '1',
+ metadata: {
+ ...AAVE_METADATA,
+ productName: 'Aave V3 Borrow',
+ positionType: 'lending',
+ },
+ },
+ ],
+ },
+ ]);
+
+ const [group] = groupDeFiPositionsV6(response)['account-1'];
+
+ expect(group.marketValue).toBe(1500);
+ expect(group.sections[0].positions[0].marketValue).toBe(2000);
+ expect(group.sections[1].positions[0].marketValue).toBe(500);
+ expect(group.sections[1].positions[0].positionType).toBe('lending');
+ });
+
+ it('creates separate detail sections per productName under one protocolId', () => {
+ const response = buildResponse([
+ {
+ accountId: 'account-1',
+ balances: [
+ {
+ category: 'defi',
+ assetId: WETH_ASSET_ID,
+ name: 'Wrapped Ether',
+ symbol: 'WETH',
+ decimals: 18,
+ balance: '1',
+ price: '2000',
+ metadata: {
+ ...AAVE_METADATA,
+ productName: 'Aave V3 Supply',
+ },
+ },
+ {
+ category: 'defi',
+ assetId: USDC_ASSET_ID,
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ balance: '100',
+ price: '1',
+ metadata: {
+ ...AAVE_METADATA,
+ productName: 'Aave V3 Borrow',
+ positionType: 'lending',
+ },
+ },
+ ],
+ },
+ ]);
+
+ const [group] = groupDeFiPositionsV6(response)['account-1'];
+
+ expect(group.productName).toBe('Aave V3 Supply');
+ expect(group.sections).toStrictEqual([
+ {
+ productName: 'Aave V3 Supply',
+ positions: [
+ expect.objectContaining({
+ symbol: 'WETH',
+ positionType: 'deposit',
+ }),
+ ],
+ },
+ {
+ productName: 'Aave V3 Borrow',
+ positions: [
+ expect.objectContaining({
+ symbol: 'USDC',
+ positionType: 'lending',
+ }),
+ ],
+ },
+ ]);
+ });
+
+ it('dedupes icon-group symbols and moves ETH/WETH to the front', () => {
+ const response = buildResponse([
+ {
+ accountId: 'account-1',
+ balances: [
+ {
+ category: 'defi',
+ assetId: USDC_ASSET_ID,
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ balance: '100',
+ price: '1',
+ metadata: AAVE_METADATA,
+ },
+ {
+ category: 'defi',
+ assetId: WETH_ASSET_ID,
+ name: 'Wrapped Ether',
+ symbol: 'WETH',
+ decimals: 18,
+ balance: '1',
+ price: '2000',
+ metadata: AAVE_METADATA,
+ },
+ {
+ category: 'defi',
+ assetId: WETH_ASSET_ID,
+ name: 'Wrapped Ether',
+ symbol: 'WETH',
+ decimals: 18,
+ balance: '0.5',
+ price: '2000',
+ metadata: {
+ ...AAVE_METADATA,
+ positionType: 'rewards',
+ },
+ },
+ ],
+ },
+ ]);
+
+ const [group] = groupDeFiPositionsV6(response)['account-1'];
+
+ expect(group.iconGroup.map((item) => item.symbol)).toStrictEqual([
+ 'WETH',
+ 'USDC',
+ ]);
+ expect(group.iconGroup[0].avatarValue).toBe(
+ 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2.png',
+ );
+ });
+
+ it('builds underlying positions with token images and chain IDs', () => {
+ const response = buildResponse([
+ {
+ accountId: 'account-1',
+ balances: [
+ {
+ category: 'defi',
+ assetId: WETH_ASSET_ID,
+ name: 'Wrapped Ether',
+ symbol: 'WETH',
+ decimals: 18,
+ balance: '1.5',
+ price: '2000',
+ metadata: AAVE_METADATA,
+ },
+ ],
+ },
+ ]);
+
+ const [group] = groupDeFiPositionsV6(response)['account-1'];
+ const [position] = group.sections[0].positions;
+
+ expect(position).toStrictEqual({
+ assetId: WETH_ASSET_ID,
+ chainId: 'eip155:1',
+ symbol: 'WETH',
+ name: 'Wrapped Ether',
+ decimals: 18,
+ balance: '1.5',
+ marketValue: 3000,
+ positionType: 'deposit',
+ poolAddress: '0xpool',
+ groupId: 'group-aave-1',
+ tokenImage:
+ 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2.png',
+ });
+ });
+
+ it('keeps distinct groupIds on positions that share a productName', () => {
+ const response = buildResponse([
+ {
+ accountId: 'account-1',
+ balances: [
+ {
+ category: 'defi',
+ assetId: WETH_ASSET_ID,
+ name: 'Wrapped Ether',
+ symbol: 'WETH',
+ decimals: 18,
+ balance: '1',
+ price: '2000',
+ metadata: {
+ ...AAVE_METADATA,
+ productName: 'Pendle YT',
+ groupId: 'group-yt-1',
+ },
+ },
+ {
+ category: 'defi',
+ assetId: USDC_ASSET_ID,
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ balance: '100',
+ price: '1',
+ metadata: {
+ ...AAVE_METADATA,
+ productName: 'Pendle YT',
+ poolAddress: '0xpool2',
+ groupId: 'group-yt-2',
+ },
+ },
+ ],
+ },
+ ]);
+
+ const [group] = groupDeFiPositionsV6(response)['account-1'];
+
+ expect(group.sections).toHaveLength(1);
+ expect(group.sections[0].productName).toBe('Pendle YT');
+ expect(
+ group.sections[0].positions.map((position) => position.groupId),
+ ).toStrictEqual(['group-yt-1', 'group-yt-2']);
+ });
+});
diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts
new file mode 100644
index 00000000000..9ea4de4dfb3
--- /dev/null
+++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts
@@ -0,0 +1,392 @@
+import { V6_DEFI_POSITION_TYPES } from '@metamask/core-backend';
+import type {
+ V6BalanceItem,
+ V6BalanceMetadata,
+ V6BalancesResponse,
+ V6DeFiPositionType,
+} from '@metamask/core-backend';
+import type {
+ CaipAccountId,
+ CaipAssetType,
+ CaipChainId,
+} from '@metamask/utils';
+import {
+ KnownCaipNamespace,
+ parseCaipAccountId,
+ parseCaipAssetType,
+ parseCaipChainId,
+} from '@metamask/utils';
+
+/** Static.cx host used to build CAIP-19 token icon URLs for DeFi positions. */
+const STATIC_METAMASK_BASE_URL = 'https://static.cx.metamask.io';
+
+/**
+ * Possible `positionType` values from Accounts API v6 DeFi metadata.
+ * Re-export of {@link V6_DEFI_POSITION_TYPES} from `@metamask/core-backend`.
+ */
+export const DEFI_POSITION_TYPES = V6_DEFI_POSITION_TYPES;
+
+/**
+ * The specific module or functionality within a DeFi protocol where a position
+ * is held. Alias of {@link V6DeFiPositionType} from `@metamask/core-backend`.
+ */
+export type DeFiPositionType = V6DeFiPositionType;
+
+/**
+ * Position types whose fiat value is a liability and is subtracted from the
+ * protocol group's aggregated `marketValue`.
+ */
+export const DEFI_POSITION_LIABILITY_TYPES: ReadonlySet =
+ new Set(['lending']);
+
+/**
+ * An icon-group entry shown next to a protocol in the DeFi tab list.
+ */
+export type DeFiPositionIconGroupItem = {
+ /** Token icon URL, when one can be built for the asset. */
+ avatarValue?: string;
+ symbol: string;
+};
+
+/**
+ * A single underlying position row shown on the DeFi details page.
+ */
+export type DeFiUnderlyingPosition = {
+ assetId: CaipAssetType;
+ chainId: CaipChainId;
+ symbol: string;
+ name: string;
+ decimals: number;
+ /** Raw balance string as returned by the API. */
+ balance: string;
+ /** Fiat market value in the requested currency, when a price is available. */
+ marketValue?: number;
+ /** Position type from protocol metadata. */
+ positionType: DeFiPositionType;
+ /** Address of the pool this position belongs to. */
+ poolAddress: string;
+ /**
+ * Upstream grouping id from the API. Rows that share a `productName` can
+ * still carry distinct `groupId`s (e.g. multiple Pendle YT markets).
+ */
+ groupId: string;
+ /** Token icon URL, when one can be built for the asset. */
+ tokenImage?: string;
+};
+
+/**
+ * A section of the details page, grouping positions that share the same
+ * API `productName`. A single `protocolId` can have multiple products
+ * (different pools/markets under one protocol), so a group may contain
+ * several sections.
+ */
+export type DeFiPositionDetailsSection = {
+ /** Section label from the API (`metadata.productName`). */
+ productName: string;
+ positions: DeFiUnderlyingPosition[];
+};
+
+/**
+ * One row in the DeFi tab list (a protocol on a given chain), with the details
+ * needed to render the details page embedded directly inside it.
+ */
+export type DeFiProtocolPositionGroup = {
+ protocolId: string;
+ /**
+ * Product name from the first position seen for this protocol. Prefer
+ * `protocolId` for the list-row title; use section `productName`s for
+ * per-product detail headings.
+ */
+ productName: string;
+ protocolIconUrl: string;
+ chainId: CaipChainId;
+ /**
+ * Aggregated fiat market value across all positions in the group.
+ * `lending` positions are subtracted; all other types are added.
+ */
+ marketValue: number;
+ /** Icon-group entries for the list row. */
+ iconGroup: DeFiPositionIconGroupItem[];
+ /**
+ * Detail sections consumed by the details page, one per distinct API
+ * `productName` under this `protocolId`.
+ */
+ sections: DeFiPositionDetailsSection[];
+};
+
+/**
+ * DeFi positions for every queried account, keyed by the internal MetaMask
+ * account ID (`InternalAccount.id` UUID), the same key AssetsController uses.
+ * Each account maps to a flat list of protocol groups; filter by each group's
+ * `chainId` rather than digging through a nested chain map.
+ */
+export type DeFiPositionsByAccount = {
+ [accountId: string]: DeFiProtocolPositionGroup[];
+};
+
+// Prefer ETH/WETH first in the list-row icon stack when a protocol has multiple
+// underlyings. Display-only.
+const SYMBOL_PRIORITY = ['ETH', 'WETH'];
+
+type DefiBalanceWithMetadata = V6BalanceItem & { metadata: V6BalanceMetadata };
+
+/**
+ * Builds a static token icon URL for a CAIP asset ID.
+ *
+ * @param assetId - The CAIP-19 asset ID.
+ * @returns The token icon URL, or `undefined` when it cannot be built.
+ */
+function getDefiTokenImageUrl(assetId: CaipAssetType): string | undefined {
+ try {
+ const { chainId } = parseCaipAssetType(assetId);
+ const { namespace } = parseCaipChainId(chainId);
+ const isEvm = namespace === KnownCaipNamespace.Eip155;
+ const normalizedAssetId = (isEvm ? assetId.toLowerCase() : assetId).replace(
+ /:/gu,
+ '/',
+ );
+
+ return `${STATIC_METAMASK_BASE_URL}/api/v2/tokenIcons/assets/${normalizedAssetId}.png`;
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Returns whether a balance row is a DeFi position carrying protocol metadata.
+ *
+ * @param balance - A balance row from the v6 API.
+ * @returns True when the row is a `category: defi` row with protocol metadata.
+ */
+function isDefiBalanceWithMetadata(
+ balance: V6BalanceItem,
+): balance is DefiBalanceWithMetadata {
+ return (
+ balance.category === 'defi' &&
+ balance.metadata !== undefined &&
+ (balance.metadata as Partial).protocolId !== undefined
+ );
+}
+
+/**
+ * Returns the fiat market value for a v6 DeFi balance row.
+ *
+ * @param balance - A balance row from the v6 API.
+ * @returns The fiat value, or `undefined` when price is missing or the
+ * balance/price is invalid.
+ */
+function getMarketValue(balance: V6BalanceItem): number | undefined {
+ if (balance.price === undefined) {
+ return undefined;
+ }
+
+ const normalizedBalance = Number.parseFloat(balance.balance);
+ const price = Number.parseFloat(balance.price);
+
+ if (!Number.isFinite(normalizedBalance) || !Number.isFinite(price)) {
+ return undefined;
+ }
+
+ return normalizedBalance * price;
+}
+
+/**
+ * Returns the sign used when rolling a position's market value into a protocol
+ * group total. Liability types (currently `lending`) subtract; all others add.
+ *
+ * @param positionType - The position's protocol module type.
+ * @returns `-1` for liabilities, `1` otherwise.
+ */
+function getMarketValueSign(positionType: DeFiPositionType): 1 | -1 {
+ return DEFI_POSITION_LIABILITY_TYPES.has(positionType) ? -1 : 1;
+}
+
+/**
+ * Moves a priority symbol (ETH/WETH) to the front of the icon group, in place.
+ *
+ * @param iconGroup - The icon-group entries to reorder.
+ */
+function orderIconGroup(iconGroup: DeFiPositionIconGroupItem[]): void {
+ const priorityIndex = iconGroup.findIndex((item) =>
+ SYMBOL_PRIORITY.includes(item.symbol),
+ );
+
+ if (priorityIndex > 0) {
+ const [priorityIcon] = iconGroup.splice(priorityIndex, 1);
+ iconGroup.unshift(priorityIcon);
+ }
+}
+
+/**
+ * Maps a DeFi balance row to a details-page underlying position.
+ *
+ * @param balance - A DeFi balance row with protocol metadata.
+ * @returns The underlying position for the details page.
+ */
+function toUnderlyingPosition(
+ balance: DefiBalanceWithMetadata,
+): DeFiUnderlyingPosition {
+ const assetId = balance.assetId as CaipAssetType;
+ const { chainId } = parseCaipAssetType(assetId);
+ const { positionType, poolAddress, groupId } = balance.metadata;
+
+ return {
+ assetId,
+ chainId,
+ symbol: balance.symbol,
+ name: balance.name,
+ balance: balance.balance,
+ decimals: balance.decimals,
+ marketValue: getMarketValue(balance),
+ positionType,
+ poolAddress,
+ groupId,
+ tokenImage: getDefiTokenImageUrl(assetId),
+ };
+}
+
+/**
+ * Builds a chain-reference-agnostic key (`namespace:address`) for matching the
+ * CAIP-10 account IDs we request against the ones the v6 API echoes back.
+ *
+ * We request EVM balances with the all-chains reference (`eip155:0:`),
+ * but the response echoes a separate per-chain ID for every chain
+ * (`eip155:1:`, `eip155:137:`, ...). Matching on the full
+ * CAIP-10 string therefore fails, so we drop the reference and match on
+ * namespace + address instead. EVM addresses are lowercased; other namespaces
+ * keep their case.
+ *
+ * @param caipAccountId - A CAIP-10 account ID.
+ * @returns The match key, or a case-normalized fallback if parsing fails.
+ */
+function toAccountMatchKey(caipAccountId: string): string {
+ try {
+ const {
+ chain: { namespace },
+ address,
+ } = parseCaipAccountId(caipAccountId as CaipAccountId);
+ const normalizedAddress =
+ namespace === KnownCaipNamespace.Eip155 ? address.toLowerCase() : address;
+ return `${namespace}:${normalizedAddress}`;
+ } catch {
+ return caipAccountId.startsWith(`${KnownCaipNamespace.Eip155}:`)
+ ? caipAccountId.toLowerCase()
+ : caipAccountId;
+ }
+}
+
+/**
+ * Transforms a v6 multiaccount balances response into the stored DeFi state:
+ * positions keyed by internal account ID, each mapping to a flat list of
+ * protocol groups. Every group carries its own `chainId` (so the client can
+ * filter without a nested chain map) plus both the DeFi-tab summary and the
+ * details-page sections. Accounts present in the response but with no DeFi
+ * positions are included with an empty list so stale data is cleared.
+ *
+ * When `internalAccountIdByCaip` is provided, response account IDs are matched
+ * to internal MetaMask account IDs via namespace + address (ignoring chain
+ * reference and EVM case). Unmatched accounts are skipped. When omitted, the
+ * response account ID is used as-is (handy for unit tests).
+ *
+ * @param response - The v6 multiaccount balances response.
+ * @param internalAccountIdByCaip - Optional map of request CAIP-10 account IDs
+ * to internal MetaMask account IDs.
+ * @returns DeFi positions keyed by internal account ID.
+ */
+export function groupDeFiPositionsV6(
+ response: V6BalancesResponse,
+ internalAccountIdByCaip?: Map,
+): DeFiPositionsByAccount {
+ const internalAccountIdByMatchKey = internalAccountIdByCaip
+ ? new Map(
+ [...internalAccountIdByCaip].map(([caipAccountId, internalId]) => [
+ toAccountMatchKey(caipAccountId),
+ internalId,
+ ]),
+ )
+ : undefined;
+
+ // Accumulate groups per resolved internal account ID. The v6 response returns
+ // a separate entry per chain (e.g. `eip155:1:`, `eip155:137:`),
+ // and several of them can resolve to the same internal account ID, so we must
+ // merge across all of them rather than overwrite per response account.
+ const groupsByAccountKey = new Map<
+ string,
+ Map
+ >();
+
+ for (const account of response.accounts) {
+ const accountId = internalAccountIdByMatchKey
+ ? internalAccountIdByMatchKey.get(toAccountMatchKey(account.accountId))
+ : account.accountId;
+ if (accountId === undefined) {
+ continue;
+ }
+
+ // Seed every queried account so accounts that no longer hold positions
+ // overwrite (clear) any previously stored data.
+ let groupsByKey = groupsByAccountKey.get(accountId);
+ if (!groupsByKey) {
+ groupsByKey = new Map();
+ groupsByAccountKey.set(accountId, groupsByKey);
+ }
+
+ for (const balance of account.balances) {
+ if (!isDefiBalanceWithMetadata(balance)) {
+ continue;
+ }
+
+ const position = toUnderlyingPosition(balance);
+ const { protocolId, productName, protocolIconUrl } = balance.metadata;
+ const groupKey = `${position.chainId}#${protocolId}`;
+
+ let group = groupsByKey.get(groupKey);
+ if (!group) {
+ group = {
+ protocolId,
+ productName,
+ protocolIconUrl,
+ chainId: position.chainId,
+ marketValue: 0,
+ iconGroup: [],
+ sections: [],
+ };
+ groupsByKey.set(groupKey, group);
+ }
+
+ if (position.marketValue !== undefined) {
+ group.marketValue +=
+ position.marketValue * getMarketValueSign(position.positionType);
+ }
+
+ if (!group.iconGroup.some((item) => item.symbol === position.symbol)) {
+ group.iconGroup.push({
+ symbol: position.symbol,
+ avatarValue: position.tokenImage,
+ });
+ }
+
+ // Sections are keyed by productName; distinct groupIds under the same
+ // product remain available on each underlying position.
+ let section = group.sections.find(
+ (item) => item.productName === productName,
+ );
+ if (!section) {
+ section = { productName, positions: [] };
+ group.sections.push(section);
+ }
+ section.positions.push(position);
+ }
+ }
+
+ const result: DeFiPositionsByAccount = {};
+ for (const [accountId, groupsByKey] of groupsByAccountKey) {
+ const groups = [...groupsByKey.values()];
+ for (const group of groups) {
+ orderIconGroup(group.iconGroup);
+ }
+ result[accountId] = groups;
+ }
+
+ return result;
+}
diff --git a/packages/assets-controllers/src/DeFiPositionsController/merge-positions-for-accounts.test.ts b/packages/assets-controllers/src/DeFiPositionsController/merge-positions-for-accounts.test.ts
new file mode 100644
index 00000000000..7b8ff135935
--- /dev/null
+++ b/packages/assets-controllers/src/DeFiPositionsController/merge-positions-for-accounts.test.ts
@@ -0,0 +1,202 @@
+import type { CaipAssetType, CaipChainId } from '@metamask/utils';
+
+import type {
+ DeFiProtocolPositionGroup,
+ DeFiUnderlyingPosition,
+} from './group-defi-positions-v6';
+import { mergePositionsForAccounts } from './merge-positions-for-accounts';
+
+const ETH_MAINNET = 'eip155:1' as CaipChainId;
+const BASE = 'eip155:8453' as CaipChainId;
+
+const WETH_ASSET_ID =
+ 'eip155:1/erc20:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' as CaipAssetType;
+const USDC_ASSET_ID =
+ 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as CaipAssetType;
+
+/**
+ * Builds an underlying position for tests.
+ *
+ * @param overrides - Fields to override on the default position.
+ * @returns An underlying position.
+ */
+function buildPosition(
+ overrides: Partial = {},
+): DeFiUnderlyingPosition {
+ return {
+ assetId: WETH_ASSET_ID,
+ chainId: ETH_MAINNET,
+ symbol: 'WETH',
+ name: 'Wrapped Ether',
+ decimals: 18,
+ balance: '1',
+ marketValue: 2000,
+ positionType: 'deposit',
+ poolAddress: '0xpool',
+ groupId: 'group-1',
+ tokenImage: 'https://example.com/weth.png',
+ ...overrides,
+ };
+}
+
+/**
+ * Builds a protocol position group for tests.
+ *
+ * @param overrides - Fields to override on the default group.
+ * @returns A protocol position group.
+ */
+function buildGroup(
+ overrides: Partial = {},
+): DeFiProtocolPositionGroup {
+ return {
+ protocolId: 'aave-v3',
+ productName: 'Aave V3',
+ protocolIconUrl: 'https://example.com/aave.png',
+ chainId: ETH_MAINNET,
+ marketValue: 2000,
+ iconGroup: [
+ { symbol: 'WETH', avatarValue: 'https://example.com/weth.png' },
+ ],
+ sections: [{ productName: 'Aave V3', positions: [buildPosition()] }],
+ ...overrides,
+ };
+}
+
+describe('mergePositionsForAccounts', () => {
+ it('returns an empty list when no accounts have positions', () => {
+ expect(mergePositionsForAccounts({}, ['account-1'])).toStrictEqual([]);
+ });
+
+ it('returns a single account’s groups unchanged in content', () => {
+ const group = buildGroup();
+
+ const result = mergePositionsForAccounts({ 'account-1': [group] }, [
+ 'account-1',
+ ]);
+
+ expect(result).toStrictEqual([group]);
+ });
+
+ it('does not mutate the source groups held in state', () => {
+ const group = buildGroup();
+ const positionsByAccount = { 'account-1': [group] };
+
+ const result = mergePositionsForAccounts(positionsByAccount, ['account-1']);
+ result[0].marketValue = 999;
+ result[0].iconGroup.push({ symbol: 'HACK' });
+ result[0].sections.push({ productName: 'HACK', positions: [] });
+ result[0].sections[0].positions.push(buildPosition({ symbol: 'HACK' }));
+
+ expect(group.marketValue).toBe(2000);
+ expect(group.iconGroup).toHaveLength(1);
+ expect(group.sections).toHaveLength(1);
+ expect(group.sections[0].positions).toHaveLength(1);
+ });
+
+ it('keeps groups on the same protocol but different chains separate', () => {
+ const ethGroup = buildGroup({ chainId: ETH_MAINNET });
+ const baseGroup = buildGroup({ chainId: BASE });
+
+ const result = mergePositionsForAccounts(
+ { 'account-1': [ethGroup], 'account-2': [baseGroup] },
+ ['account-1', 'account-2'],
+ );
+
+ expect(result).toHaveLength(2);
+ expect(result.map((group) => group.chainId)).toStrictEqual([
+ ETH_MAINNET,
+ BASE,
+ ]);
+ });
+
+ it('merges groups that share chain and protocol across accounts', () => {
+ const groupA = buildGroup({
+ marketValue: 2000,
+ iconGroup: [{ symbol: 'WETH' }],
+ sections: [
+ {
+ productName: 'Aave V3',
+ positions: [buildPosition({ symbol: 'WETH' })],
+ },
+ ],
+ });
+ const groupB = buildGroup({
+ marketValue: 500,
+ iconGroup: [{ symbol: 'USDC' }],
+ sections: [
+ {
+ productName: 'Aave V3',
+ positions: [
+ buildPosition({ symbol: 'USDC', assetId: USDC_ASSET_ID }),
+ ],
+ },
+ ],
+ });
+
+ const result = mergePositionsForAccounts(
+ { 'account-1': [groupA], 'account-2': [groupB] },
+ ['account-1', 'account-2'],
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].marketValue).toBe(2500);
+ expect(result[0].iconGroup.map((icon) => icon.symbol)).toStrictEqual([
+ 'WETH',
+ 'USDC',
+ ]);
+ expect(result[0].sections).toHaveLength(1);
+ expect(result[0].sections[0].positions).toHaveLength(2);
+ });
+
+ it('deduplicates icon entries that share a symbol when merging', () => {
+ const groupA = buildGroup({ iconGroup: [{ symbol: 'WETH' }] });
+ const groupB = buildGroup({ iconGroup: [{ symbol: 'WETH' }] });
+
+ const result = mergePositionsForAccounts(
+ { 'account-1': [groupA], 'account-2': [groupB] },
+ ['account-1', 'account-2'],
+ );
+
+ expect(result[0].iconGroup).toHaveLength(1);
+ });
+
+ it('ignores account IDs that are not in the selected group', () => {
+ const result = mergePositionsForAccounts(
+ { 'account-1': [buildGroup()], 'account-2': [buildGroup()] },
+ ['account-1'],
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('keeps sections with distinct productNames separate when merging', () => {
+ const groupA = buildGroup({
+ sections: [
+ {
+ productName: 'Aave V3',
+ positions: [buildPosition({ symbol: 'WETH' })],
+ },
+ ],
+ });
+ const groupB = buildGroup({
+ sections: [
+ {
+ productName: 'Pendle',
+ positions: [
+ buildPosition({ symbol: 'USDC', assetId: USDC_ASSET_ID }),
+ ],
+ },
+ ],
+ });
+
+ const result = mergePositionsForAccounts(
+ { 'account-1': [groupA], 'account-2': [groupB] },
+ ['account-1', 'account-2'],
+ );
+
+ expect(result).toHaveLength(1);
+ expect(
+ result[0].sections.map((section) => section.productName),
+ ).toStrictEqual(['Aave V3', 'Pendle']);
+ });
+});
diff --git a/packages/assets-controllers/src/DeFiPositionsController/merge-positions-for-accounts.ts b/packages/assets-controllers/src/DeFiPositionsController/merge-positions-for-accounts.ts
new file mode 100644
index 00000000000..dac70047a49
--- /dev/null
+++ b/packages/assets-controllers/src/DeFiPositionsController/merge-positions-for-accounts.ts
@@ -0,0 +1,89 @@
+import type {
+ DeFiPositionDetailsSection,
+ DeFiPositionsByAccount,
+ DeFiProtocolPositionGroup,
+} from './group-defi-positions-v6';
+
+/**
+ * Merges details-page sections that share the same `productName`, appending
+ * positions rather than keeping them as separate adjacent sections.
+ *
+ * @param existingSections - Sections already collected for this protocol group.
+ * @param incomingSections - Sections from another account holding the same
+ * protocol, to be merged in.
+ * @returns The merged sections, one per distinct `productName`.
+ */
+function mergeSections(
+ existingSections: DeFiPositionDetailsSection[],
+ incomingSections: DeFiPositionDetailsSection[],
+): DeFiPositionDetailsSection[] {
+ const byProductName = new Map(
+ existingSections.map((section) => [
+ section.productName,
+ { ...section, positions: [...section.positions] },
+ ]),
+ );
+
+ for (const section of incomingSections) {
+ const existing = byProductName.get(section.productName);
+
+ if (!existing) {
+ byProductName.set(section.productName, {
+ ...section,
+ positions: [...section.positions],
+ });
+ continue;
+ }
+
+ existing.positions.push(...section.positions);
+ }
+
+ return [...byProductName.values()];
+}
+
+/**
+ * Merges the protocol groups of every account in the selected group into a
+ * single flat list, combining groups that share the same chain and protocol.
+ *
+ * The controller stores DeFi positions keyed per internal account, but every
+ * client surface consumes the selected account group as a single merged list.
+ * This helper is exported so both clients share one implementation rather than
+ * each maintaining a copy.
+ *
+ * @param positionsByAccount - DeFi positions keyed by internal account ID.
+ * @param accountIds - Internal account IDs in the selected account group.
+ * @returns The merged protocol groups.
+ */
+export function mergePositionsForAccounts(
+ positionsByAccount: DeFiPositionsByAccount,
+ accountIds: string[],
+): DeFiProtocolPositionGroup[] {
+ const byKey = new Map();
+
+ for (const accountId of accountIds) {
+ for (const group of positionsByAccount[accountId] ?? []) {
+ const key = `${group.chainId}#${group.protocolId}`;
+ const existing = byKey.get(key);
+
+ if (!existing) {
+ // Clone so we never mutate the object held in client state.
+ byKey.set(key, {
+ ...group,
+ iconGroup: [...group.iconGroup],
+ sections: mergeSections([], group.sections),
+ });
+ continue;
+ }
+
+ existing.marketValue += group.marketValue;
+ for (const icon of group.iconGroup) {
+ if (!existing.iconGroup.some((item) => item.symbol === icon.symbol)) {
+ existing.iconGroup.push(icon);
+ }
+ }
+ existing.sections = mergeSections(existing.sections, group.sections);
+ }
+ }
+
+ return [...byKey.values()];
+}
diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts
index a005d08481b..e6d8103df00 100644
--- a/packages/assets-controllers/src/index.ts
+++ b/packages/assets-controllers/src/index.ts
@@ -265,6 +265,32 @@ export type {
DeFiPositionsControllerMessenger,
} from './DeFiPositionsController/DeFiPositionsController';
export type { GroupedDeFiPositions } from './DeFiPositionsController/group-defi-positions';
+export {
+ DeFiPositionsControllerV2,
+ getDefaultDeFiPositionsControllerV2State,
+} from './DeFiPositionsController/DeFiPositionsControllerV2';
+export type {
+ DeFiPositionsControllerV2State,
+ DeFiPositionsControllerV2Actions,
+ DeFiPositionsControllerV2Events,
+ DeFiPositionsControllerV2GetStateAction,
+ DeFiPositionsControllerV2StateChangedEvent,
+ DeFiPositionsControllerV2Messenger,
+} from './DeFiPositionsController/DeFiPositionsControllerV2';
+export type { DeFiPositionsControllerV2FetchDeFiPositionsAction } from './DeFiPositionsController/DeFiPositionsControllerV2-method-action-types';
+export {
+ DEFI_POSITION_TYPES,
+ DEFI_POSITION_LIABILITY_TYPES,
+} from './DeFiPositionsController/group-defi-positions-v6';
+export type {
+ DeFiPositionsByAccount,
+ DeFiProtocolPositionGroup,
+ DeFiPositionDetailsSection,
+ DeFiUnderlyingPosition,
+ DeFiPositionIconGroupItem,
+ DeFiPositionType,
+} from './DeFiPositionsController/group-defi-positions-v6';
+export { mergePositionsForAccounts } from './DeFiPositionsController/merge-positions-for-accounts';
export type {
AccountGroupBalance,
WalletBalance,