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. +
+
+ +
+ + + +
+ +
+ + + + 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,