From 551e3c3bdcbdd50f6e373eb48541638aea094681 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 14 Jul 2026 14:07:38 +0100 Subject: [PATCH 01/20] feat: defi controller v2 --- defi-visualizer.html | 714 ++++++++++++++++++ ...sitionsControllerV2-method-action-types.ts | 27 + .../DeFiPositionsControllerV2.ts | 262 +++++++ .../build-defi-balances-query.ts | 141 ++++ .../group-defi-positions-v6.ts | 340 +++++++++ packages/assets-controllers/src/index.ts | 23 + 6 files changed, 1507 insertions(+) create mode 100644 defi-visualizer.html create mode 100644 packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts create mode 100644 packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts create mode 100644 packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts create mode 100644 packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts diff --git a/defi-visualizer.html b/defi-visualizer.html new file mode 100644 index 00000000000..82611c6284b --- /dev/null +++ b/defi-visualizer.html @@ -0,0 +1,714 @@ + + + + + +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..ebc596f4e98 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts @@ -0,0 +1,27 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { DeFiPositionsControllerV2 } from './DeFiPositionsControllerV2'; + +/** + * Fetches DeFi positions for the selected account group and stores them, + * shaped for direct client consumption. Everything happens behind this + * method: resolving the accounts, calling the Accounts API, transforming the + * response, and updating state. + * + * Throttled per set of accounts by an in-memory minimum interval, so repeated + * calls within the window are no-ops. Disabled controllers and empty account + * groups return without fetching. + */ +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.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts new file mode 100644 index 00000000000..d5a50994b0b --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -0,0 +1,262 @@ +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, + DEFI_BALANCES_V6_REQUEST_OPTIONS, + normalizeCaipAccountId, +} from './build-defi-balances-query'; +import type { DeFiPositionsByAccount } from './group-defi-positions-v6'; +import { groupDeFiPositionsV6 } from './group-defi-positions-v6'; +import type { DeFiPositionsControllerV2MethodActions } from './DeFiPositionsControllerV2-method-action-types'; + +const controllerName = 'DeFiPositionsControllerV2'; + +const ONE_MINUTE_IN_MS = 60_000; + +const MESSENGER_EXPOSED_METHODS = ['fetchDeFiPositions'] as const; + +export type { + DeFiPositionsByAccount, + DeFiProtocolPositionGroup, + DeFiPositionDetailsSection, + DeFiPositionPoolGroup, + DeFiUnderlyingPosition, + DeFiPositionIconGroupItem, +} from './group-defi-positions-v6'; + +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. + */ + allDeFiPositions: DeFiPositionsByAccount; +}; + +const controllerMetadata: StateMetadata = { + allDeFiPositions: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, +}; + +export const getDefaultDeFiPositionsControllerV2State = + (): DeFiPositionsControllerV2State => { + return { + allDeFiPositions: {}, + }; + }; + +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 for now. When wiring the controller into a client, events such as + * `KeyringController:lock`, `TransactionController:transactionConfirmed`, and + * `AccountTreeController:selectedAccountGroupChange` can be added here and + * subscribed to in order to trigger/clear fetches. + */ +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. + */ +export class DeFiPositionsControllerV2 extends BaseController< + typeof controllerName, + DeFiPositionsControllerV2State, + DeFiPositionsControllerV2Messenger +> { + readonly #apiClient: ApiPlatformClient; + + readonly #isEnabled: () => boolean; + + readonly #getVsCurrency: () => string; + + readonly #minimumFetchIntervalMs: number; + + /** + * In-memory timestamp (ms) of the last fetch per query, keyed by the sorted + * account IDs. Intentionally not persisted: it resets on restart, so the + * first fetch after a restart always goes through. + */ + readonly #lastFetchByKey = new Map(); + + /** + * @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.minimumFetchIntervalMs - Minimum time between fetches for the same accounts (default: 1 minute). + * @param options.state - Initial controller state. + */ + constructor({ + messenger, + apiClient, + isEnabled = (): boolean => false, + getVsCurrency = (): string => DEFI_BALANCES_V6_REQUEST_OPTIONS.vsCurrency, + minimumFetchIntervalMs = ONE_MINUTE_IN_MS, + state, + }: { + messenger: DeFiPositionsControllerV2Messenger; + apiClient: ApiPlatformClient; + isEnabled?: () => boolean; + getVsCurrency?: () => string; + minimumFetchIntervalMs?: number; + state?: Partial; + }) { + super({ + name: controllerName, + metadata: controllerMetadata, + messenger, + state: { + ...getDefaultDeFiPositionsControllerV2State(), + ...state, + }, + }); + + this.#apiClient = apiClient; + this.#isEnabled = isEnabled; + this.#getVsCurrency = getVsCurrency; + this.#minimumFetchIntervalMs = minimumFetchIntervalMs; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Fetches DeFi positions for the selected account group and stores them, + * shaped for direct client consumption. Everything happens behind this + * method: resolving the accounts, calling the Accounts API, transforming the + * response, and updating state. + * + * Throttled per set of accounts by an in-memory minimum interval, so repeated + * calls within the window are no-ops. Disabled controllers and empty account + * groups return without fetching. + */ + async fetchDeFiPositions(): Promise { + if (!this.#isEnabled()) { + return; + } + + const accounts = this.messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ); + + const { + accounts: accountQueries, + accountIds, + networks, + } = buildDeFiBalancesQuery(accounts); + + if (accountIds.length === 0 || networks.length === 0) { + return; + } + + // The v6 response echoes the CAIP-10 IDs we sent; map them back to the + // internal account IDs used to key state. + const internalAccountIdByCaip = new Map( + accountQueries.map((account) => [ + normalizeCaipAccountId(account.caipAccountId), + account.internalAccountId, + ]), + ); + const resolveAccountId = (responseAccountId: string): string => + internalAccountIdByCaip.get(normalizeCaipAccountId(responseAccountId)) ?? + responseAccountId; + + const throttleKey = [...accountIds].sort().join(','); + const now = Date.now(); + const lastFetchedAt = this.#lastFetchByKey.get(throttleKey); + if ( + lastFetchedAt !== undefined && + now - lastFetchedAt < this.#minimumFetchIntervalMs + ) { + return; + } + // Mark before awaiting so concurrent calls within the window are throttled. + this.#lastFetchByKey.set(throttleKey, now); + + try { + const response = await this.#apiClient.accounts.fetchV6MultiAccountBalances( + accountIds, + { + networks, + includeDeFiBalances: + DEFI_BALANCES_V6_REQUEST_OPTIONS.includeDeFiBalances, + forceFetchDeFiPositions: + DEFI_BALANCES_V6_REQUEST_OPTIONS.forceFetchDeFiPositions, + includePrices: DEFI_BALANCES_V6_REQUEST_OPTIONS.includePrices, + vsCurrency: this.#getVsCurrency().toLowerCase(), + }, + ); + + const positionsByAccount = groupDeFiPositionsV6( + response, + resolveAccountId, + ); + + this.update((state) => { + for (const [accountId, positions] of Object.entries( + positionsByAccount, + )) { + state.allDeFiPositions[accountId] = positions; + } + }); + } catch (error) { + // Allow a retry before the interval elapses when a fetch fails. + this.#lastFetchByKey.delete(throttleKey); + console.error('Failed to fetch DeFi positions', error); + } + + // TODO: The previous controller emitted position-count analytics via a + // `trackEvent` hook (see calculate-defi-metrics). Deliberately dropped here; + // confirm with the analytics owners what metrics V2 needs before re-adding. + } +} 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..8ff42b9d958 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts @@ -0,0 +1,141 @@ +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. + */ +export const DEFI_SUPPORTED_NETWORKS = [ + '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', +] as const satisfies readonly CaipChainId[]; + +const SOLANA_MAINNET_CAIP_CHAIN_ID = SolScope.Mainnet as CaipChainId; + +/** + * Fixed request flags used against the v6 multiaccount balances endpoint so the + * response only carries what the DeFi views need (positions + prices). + */ +export const DEFI_BALANCES_V6_REQUEST_OPTIONS = { + includeDeFiBalances: true, + forceFetchDeFiPositions: true, + includePrices: true, + vsCurrency: 'usd', +} as const; + +/** + * A single account to query, pairing the CAIP-10 ID sent to the API with the + * internal MetaMask account ID (`InternalAccount.id`) used to key state. + */ +export type DeFiBalanceAccountQuery = { + caipAccountId: CaipAccountId; + internalAccountId: string; +}; + +export type DeFiBalancesQuery = { + /** Per-account entries linking CAIP-10 IDs to internal account IDs. */ + accounts: DeFiBalanceAccountQuery[]; + /** CAIP-10 account IDs to query (EVM and/or Solana). */ + accountIds: CaipAccountId[]; + /** CAIP-2 networks to query, deduped across accounts. */ + networks: CaipChainId[]; +}; + +/** + * Builds an EVM CAIP-10 account ID that spans every EVM chain (reference `0`). + * + * @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); +} + +/** + * Normalizes a CAIP-10 account ID for case-insensitive matching. EVM addresses + * are case-insensitive, so `eip155:*` IDs are lowercased; other namespaces + * (e.g. Solana, whose base58 addresses are case-sensitive) are left as-is. Used + * to match the CAIP IDs the v6 API echoes back to the ones we sent. + * + * @param caipAccountId - The CAIP-10 account ID. + * @returns The normalized account ID. + */ +export function normalizeCaipAccountId(caipAccountId: string): string { + return caipAccountId.startsWith(`${KnownCaipNamespace.Eip155}:`) + ? caipAccountId.toLowerCase() + : caipAccountId; +} + +/** + * 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. + * @param supportedNetworks - Networks supported by the DeFi balances API. + * @returns Account IDs and networks for the v6 multiaccount balances request. + */ +export function buildDeFiBalancesQuery( + internalAccounts: InternalAccount[], + supportedNetworks: readonly CaipChainId[] = DEFI_SUPPORTED_NETWORKS, +): DeFiBalancesQuery { + const evmNetworks = supportedNetworks.filter((network) => + network.startsWith(`${KnownCaipNamespace.Eip155}:`), + ); + const solanaNetworks = supportedNetworks.filter((network) => + network.startsWith(`${KnownCaipNamespace.Solana}:`), + ); + + const accounts: DeFiBalanceAccountQuery[] = []; + const networks: CaipChainId[] = []; + + const evmAccount = internalAccounts.find((account) => + isEvmAccountType(account.type), + ); + if (evmAccount && evmNetworks.length > 0) { + accounts.push({ + caipAccountId: toEvmCaipAccountId(evmAccount.address), + internalAccountId: 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(':'); + + accounts.push({ + caipAccountId: toCaipAccountId( + KnownCaipNamespace.Solana, + solanaReference, + solanaAccount.address, + ), + internalAccountId: solanaAccount.id, + }); + networks.push(...solanaNetworks); + } + + return { + accounts, + accountIds: accounts.map((account) => account.caipAccountId), + networks: [...new Set(networks)] as CaipChainId[], + }; +} 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..2c017076a35 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts @@ -0,0 +1,340 @@ +import type { + V6BalanceItem, + V6BalanceMetadata, + V6BalancesResponse, +} from '@metamask/core-backend'; +import type { CaipAssetType, CaipChainId } from '@metamask/utils'; +import { + KnownCaipNamespace, + parseCaipAssetType, + parseCaipChainId, +} from '@metamask/utils'; + +// TODO: The extension prototype derived token icons via +// `getCaipAssetImageUrl`/`getAssetImageUrl` (shared/lib/asset-utils). Core has +// no shared equivalent yet, so the minimal builder below is inlined. Replace it +// with a shared helper if/when one lands in core. +const STATIC_METAMASK_BASE_URL = 'https://static.cx.metamask.io'; + +/** + * An icon-group entry shown next to a protocol in the DeFi tab list. + */ +export type DeFiPositionIconGroupItem = { + 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; + /** Raw balance string as returned by the API. */ + balance: string; + /** Parsed balance amount, or 0 when invalid. */ + normalizedBalance: number; + decimals: number; + /** Fiat market value in the requested currency. */ + marketValue: number; + /** Position type from protocol metadata (e.g. supply, borrow, stake, reward). */ + positionType: string; + poolAddress: string; + tokenImage: string; +}; + +/** + * A group of underlying positions that share a pool address. + */ +export type DeFiPositionPoolGroup = { + poolAddress: string; + positions: DeFiUnderlyingPosition[]; +}; + +/** + * A section of the details page, grouping pools by protocol name. + */ +export type DeFiPositionDetailsSection = { + protocolName: string; + poolGroups: DeFiPositionPoolGroup[]; +}; + +/** + * 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; + protocolName: string; + protocolIconUrl: string; + chainId: CaipChainId; + /** Aggregated fiat market value across all positions in the group. */ + marketValue: number; + /** Symbols of the underlying tokens, ordered for display. */ + underlyingSymbols: string[]; + /** Icon-group entries for the list row. */ + iconGroup: DeFiPositionIconGroupItem[]; + /** Detail sections consumed by the details page. */ + 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[]; +}; + +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 an empty string when it cannot be built. + */ +function getDefiTokenImageUrl(assetId: CaipAssetType): string { + 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 ''; + } +} + +/** + * 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 parsed balance amount for a v6 balance row. + * + * @param balance - A balance row from the v6 API. + * @returns The parsed balance, or 0 when invalid. + */ +function getNormalizedBalance(balance: V6BalanceItem): number { + const normalizedBalance = Number.parseFloat(balance.balance); + + return Number.isFinite(normalizedBalance) ? normalizedBalance : 0; +} + +/** + * 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 0 when unavailable. + */ +function getMarketValue(balance: V6BalanceItem): number { + const normalizedBalance = getNormalizedBalance(balance); + const price = Number.parseFloat(balance.price ?? '0'); + + if (!Number.isFinite(price)) { + return 0; + } + + return normalizedBalance * price; +} + +/** + * Moves a priority symbol (ETH/WETH) to the front of the icon group. + * + * @param iconGroup - The icon-group entries to order. + * @returns The ordered icon-group entries. + */ +function orderIconGroup( + iconGroup: DeFiPositionIconGroupItem[], +): DeFiPositionIconGroupItem[] { + const orderedIcons = [...iconGroup]; + const priorityIndex = orderedIcons.findIndex((item) => + SYMBOL_PRIORITY.includes(item.symbol), + ); + + if (priorityIndex > 0) { + const [priorityIcon] = orderedIcons.splice(priorityIndex, 1); + orderedIcons.unshift(priorityIcon); + } + + return orderedIcons; +} + +/** + * 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, protocolIconUrl } = balance.metadata; + + return { + assetId, + chainId, + symbol: balance.symbol, + name: balance.name, + balance: balance.balance, + normalizedBalance: getNormalizedBalance(balance), + decimals: balance.decimals, + marketValue: getMarketValue(balance), + positionType, + poolAddress, + tokenImage: getDefiTokenImageUrl(assetId) || protocolIconUrl, + }; +} + +/** + * Mutable accumulator used while grouping a single protocol's positions. + */ +type MutableProtocolGroup = { + protocolId: string; + protocolName: string; + protocolIconUrl: string; + chainId: CaipChainId; + marketValue: number; + /** Underlying token symbol -> icon-group entry, deduped by symbol. */ + iconBySymbol: Map; + /** protocolName -> (poolAddress -> positions), for details-page sections. */ + sectionByName: Map>; +}; + +/** + * Finalizes a mutable protocol group into its stored, client-ready shape. + * + * @param group - The accumulated protocol group. + * @returns The finalized protocol position group. + */ +function finalizeGroup( + group: MutableProtocolGroup, +): DeFiProtocolPositionGroup { + const iconGroup = orderIconGroup([...group.iconBySymbol.values()]); + + const sections: DeFiPositionDetailsSection[] = [ + ...group.sectionByName.entries(), + ].map(([protocolName, poolGroups]) => ({ + protocolName, + poolGroups: [...poolGroups.entries()].map(([poolAddress, positions]) => ({ + poolAddress, + positions, + })), + })); + + return { + protocolId: group.protocolId, + protocolName: group.protocolName, + protocolIconUrl: group.protocolIconUrl, + chainId: group.chainId, + marketValue: group.marketValue, + underlyingSymbols: iconGroup.map(({ symbol }) => symbol), + iconGroup, + sections, + }; +} + +/** + * 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. + * + * The v6 response keys accounts by the CAIP-10 ID sent to the API, so + * `resolveAccountId` maps that back to the internal MetaMask account ID used to + * key state. It defaults to the identity function (leaving the response ID) for + * callers that don't need the mapping (e.g. tests). + * + * @param response - The v6 multiaccount balances response. + * @param resolveAccountId - Maps a response (CAIP-10) account ID to the internal account ID. + * @returns DeFi positions keyed by internal account ID and chain. + */ +export function groupDeFiPositionsV6( + response: V6BalancesResponse, + resolveAccountId: (responseAccountId: string) => string = (id) => id, +): DeFiPositionsByAccount { + const result: DeFiPositionsByAccount = {}; + + for (const account of response.accounts) { + // Seed every queried account so accounts that no longer hold positions + // overwrite (clear) any previously stored data. + const groupsByKey = new Map(); + + for (const balance of account.balances) { + if (!isDefiBalanceWithMetadata(balance)) { + continue; + } + + const assetId = balance.assetId as CaipAssetType; + const { chainId } = parseCaipAssetType(assetId); + const { protocolId, protocolName, protocolIconUrl, poolAddress } = + balance.metadata; + const groupKey = `${chainId}#${protocolId}`; + const marketValue = getMarketValue(balance); + const iconEntry: DeFiPositionIconGroupItem = { + symbol: balance.symbol, + avatarValue: getDefiTokenImageUrl(assetId), + }; + const position = toUnderlyingPosition(balance); + + let group = groupsByKey.get(groupKey); + if (!group) { + group = { + protocolId, + protocolName, + protocolIconUrl, + chainId, + marketValue: 0, + iconBySymbol: new Map(), + sectionByName: new Map(), + }; + groupsByKey.set(groupKey, group); + } + + group.marketValue += marketValue; + group.iconBySymbol.set(balance.symbol, iconEntry); + + let poolGroups = group.sectionByName.get(protocolName); + if (!poolGroups) { + poolGroups = new Map(); + group.sectionByName.set(protocolName, poolGroups); + } + const poolPositions = poolGroups.get(poolAddress) ?? []; + poolPositions.push(position); + poolGroups.set(poolAddress, poolPositions); + } + + result[resolveAccountId(account.accountId)] = [ + ...groupsByKey.values(), + ].map(finalizeGroup); + } + + return result; +} diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 70cadfb0156..6f446820409 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -264,6 +264,29 @@ 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, + DeFiPositionsByAccount, + DeFiProtocolPositionGroup, + DeFiPositionDetailsSection, + DeFiPositionPoolGroup, + DeFiUnderlyingPosition, + DeFiPositionIconGroupItem, +} from './DeFiPositionsController/DeFiPositionsControllerV2'; +export type { DeFiPositionsControllerV2FetchDeFiPositionsAction } from './DeFiPositionsController/DeFiPositionsControllerV2-method-action-types'; +export { + DEFI_SUPPORTED_NETWORKS, + buildDeFiBalancesQuery, +} from './DeFiPositionsController/build-defi-balances-query'; export type { AccountGroupBalance, WalletBalance, From e2afb4d3835bbd5d406fadd83ff7f436dc369624 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 14 Jul 2026 14:08:18 +0100 Subject: [PATCH 02/20] move file --- .../src/DeFiPositionsController/defi-visualizer.html | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename defi-visualizer.html => packages/assets-controllers/src/DeFiPositionsController/defi-visualizer.html (100%) diff --git a/defi-visualizer.html b/packages/assets-controllers/src/DeFiPositionsController/defi-visualizer.html similarity index 100% rename from defi-visualizer.html rename to packages/assets-controllers/src/DeFiPositionsController/defi-visualizer.html From 7e679831d1d4d28aa6735e74100346708a208d06 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 14 Jul 2026 14:18:56 +0100 Subject: [PATCH 03/20] linting --- .../DeFiPositionsControllerV2.ts | 10 +- .../build-defi-balances-query.ts | 6 +- .../defi-visualizer.html | 1691 ++++++++++------- .../group-defi-positions-v6.ts | 10 +- 4 files changed, 994 insertions(+), 723 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index d5a50994b0b..37be994d218 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -13,9 +13,9 @@ import { DEFI_BALANCES_V6_REQUEST_OPTIONS, normalizeCaipAccountId, } 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'; -import type { DeFiPositionsControllerV2MethodActions } from './DeFiPositionsControllerV2-method-action-types'; const controllerName = 'DeFiPositionsControllerV2'; @@ -224,9 +224,8 @@ export class DeFiPositionsControllerV2 extends BaseController< this.#lastFetchByKey.set(throttleKey, now); try { - const response = await this.#apiClient.accounts.fetchV6MultiAccountBalances( - accountIds, - { + const response = + await this.#apiClient.accounts.fetchV6MultiAccountBalances(accountIds, { networks, includeDeFiBalances: DEFI_BALANCES_V6_REQUEST_OPTIONS.includeDeFiBalances, @@ -234,8 +233,7 @@ export class DeFiPositionsControllerV2 extends BaseController< DEFI_BALANCES_V6_REQUEST_OPTIONS.forceFetchDeFiPositions, includePrices: DEFI_BALANCES_V6_REQUEST_OPTIONS.includePrices, vsCurrency: this.#getVsCurrency().toLowerCase(), - }, - ); + }); const positionsByAccount = groupDeFiPositionsV6( response, diff --git a/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts index 8ff42b9d958..9eab44e6fed 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts @@ -1,4 +1,8 @@ -import { isEvmAccountType, SolAccountType, SolScope } from '@metamask/keyring-api'; +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'; diff --git a/packages/assets-controllers/src/DeFiPositionsController/defi-visualizer.html b/packages/assets-controllers/src/DeFiPositionsController/defi-visualizer.html index 82611c6284b..20eca47f1cd 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/defi-visualizer.html +++ b/packages/assets-controllers/src/DeFiPositionsController/defi-visualizer.html @@ -1,714 +1,985 @@ - + - - - -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/group-defi-positions-v6.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts index 2c017076a35..7c35fd80964 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts @@ -232,9 +232,7 @@ type MutableProtocolGroup = { * @param group - The accumulated protocol group. * @returns The finalized protocol position group. */ -function finalizeGroup( - group: MutableProtocolGroup, -): DeFiProtocolPositionGroup { +function finalizeGroup(group: MutableProtocolGroup): DeFiProtocolPositionGroup { const iconGroup = orderIconGroup([...group.iconBySymbol.values()]); const sections: DeFiPositionDetailsSection[] = [ @@ -331,9 +329,9 @@ export function groupDeFiPositionsV6( poolGroups.set(poolAddress, poolPositions); } - result[resolveAccountId(account.accountId)] = [ - ...groupsByKey.values(), - ].map(finalizeGroup); + result[resolveAccountId(account.accountId)] = [...groupsByKey.values()].map( + finalizeGroup, + ); } return result; From 052b1389a94fa3a3740e3fcf1347332e386a3483 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 14 Jul 2026 15:17:50 +0100 Subject: [PATCH 04/20] progress --- .../DeFiPositionsControllerV2.ts | 66 +++++++++-------- .../build-defi-balances-query.ts | 71 +++++++++---------- .../group-defi-positions-v6.ts | 21 ++++-- 3 files changed, 83 insertions(+), 75 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index 37be994d218..948080ad83e 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -119,9 +119,16 @@ export class DeFiPositionsControllerV2 extends BaseController< readonly #minimumFetchIntervalMs: number; /** - * In-memory timestamp (ms) of the last fetch per query, keyed by the sorted - * account IDs. Intentionally not persisted: it resets on restart, so the - * first fetch after a restart always goes through. + * In-memory timestamp (ms) of the last fetch per set of accounts. + * + * This is a controller-level gate, separate from TanStack's `staleTime` inside + * `fetchV6MultiAccountBalances`: when the interval has not elapsed we + * early-return without regrouping or writing state. TanStack still dedupes + * in-flight HTTP for identical query keys; this Map skips that work entirely. + * + * Keyed by sorted CAIP account IDs only (not networks / vsCurrency). + * Intentionally not persisted: resets on restart, so the first fetch after a + * restart always goes through. */ readonly #lastFetchByKey = new Map(); @@ -137,15 +144,15 @@ export class DeFiPositionsControllerV2 extends BaseController< constructor({ messenger, apiClient, - isEnabled = (): boolean => false, - getVsCurrency = (): string => DEFI_BALANCES_V6_REQUEST_OPTIONS.vsCurrency, + isEnabled, + getVsCurrency, minimumFetchIntervalMs = ONE_MINUTE_IN_MS, state, }: { messenger: DeFiPositionsControllerV2Messenger; apiClient: ApiPlatformClient; - isEnabled?: () => boolean; - getVsCurrency?: () => string; + isEnabled: () => boolean; + getVsCurrency: () => string; minimumFetchIntervalMs?: number; state?: Partial; }) { @@ -177,40 +184,28 @@ export class DeFiPositionsControllerV2 extends BaseController< * response, and updating state. * * Throttled per set of accounts by an in-memory minimum interval, so repeated - * calls within the window are no-ops. Disabled controllers and empty account - * groups return without fetching. + * calls within the window are no-ops (no HTTP, no regroup, no state write). + * Disabled controllers and empty account groups return without fetching. */ async fetchDeFiPositions(): Promise { if (!this.#isEnabled()) { return; } - const accounts = this.messenger.call( + const selectedAccounts = this.messenger.call( 'AccountTreeController:getAccountsFromSelectedAccountGroup', ); - const { - accounts: accountQueries, - accountIds, - networks, - } = buildDeFiBalancesQuery(accounts); + const { networks, internalAccountIdByCaip } = + buildDeFiBalancesQuery(selectedAccounts); - if (accountIds.length === 0 || networks.length === 0) { + if (internalAccountIdByCaip.size === 0 || networks.length === 0) { return; } - // The v6 response echoes the CAIP-10 IDs we sent; map them back to the - // internal account IDs used to key state. - const internalAccountIdByCaip = new Map( - accountQueries.map((account) => [ - normalizeCaipAccountId(account.caipAccountId), - account.internalAccountId, - ]), - ); - const resolveAccountId = (responseAccountId: string): string => - internalAccountIdByCaip.get(normalizeCaipAccountId(responseAccountId)) ?? - responseAccountId; - + const accountIds = [...internalAccountIdByCaip.keys()]; + // Stable key so the same account set throttles together regardless of map + // iteration order. const throttleKey = [...accountIds].sort().join(','); const now = Date.now(); const lastFetchedAt = this.#lastFetchByKey.get(throttleKey); @@ -220,7 +215,9 @@ export class DeFiPositionsControllerV2 extends BaseController< ) { return; } - // Mark before awaiting so concurrent calls within the window are throttled. + // Claim the slot before awaiting so a second call that arrives while the + // first is in flight is also dropped (TanStack would share that promise; + // we intentionally skip instead). this.#lastFetchByKey.set(throttleKey, now); try { @@ -235,6 +232,13 @@ export class DeFiPositionsControllerV2 extends BaseController< vsCurrency: this.#getVsCurrency().toLowerCase(), }); + // The v6 response echoes the CAIP-10 IDs we sent; map them back to the + // internal account IDs used to key state. Unmatched accounts are skipped. + const resolveAccountId = ( + responseAccountId: string, + ): string | undefined => + internalAccountIdByCaip.get(normalizeCaipAccountId(responseAccountId)); + const positionsByAccount = groupDeFiPositionsV6( response, resolveAccountId, @@ -248,13 +252,13 @@ export class DeFiPositionsControllerV2 extends BaseController< } }); } catch (error) { - // Allow a retry before the interval elapses when a fetch fails. + // Clear the claim so a failed fetch does not burn the throttle window. this.#lastFetchByKey.delete(throttleKey); console.error('Failed to fetch DeFi positions', error); } // TODO: The previous controller emitted position-count analytics via a // `trackEvent` hook (see calculate-defi-metrics). Deliberately dropped here; - // confirm with the analytics owners what metrics V2 needs before re-adding. + // confirm what analytics will be needed before re-adding. } } diff --git a/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts index 9eab44e6fed..1c303b3c3ea 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts @@ -9,8 +9,11 @@ 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 = [ +export const DEFI_SUPPORTED_NETWORKS: readonly CaipChainId[] = [ 'eip155:1', 'eip155:137', 'eip155:56', @@ -24,37 +27,32 @@ export const DEFI_SUPPORTED_NETWORKS = [ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 'eip155:999', 'eip155:5042', -] as const satisfies readonly CaipChainId[]; +]; -const SOLANA_MAINNET_CAIP_CHAIN_ID = SolScope.Mainnet as CaipChainId; +const SOLANA_MAINNET_CAIP_CHAIN_ID: CaipChainId = SolScope.Mainnet; /** * Fixed request flags used against the v6 multiaccount balances endpoint so the * response only carries what the DeFi views need (positions + prices). + * Fiat currency (`vsCurrency`) is not fixed here — it comes from + * AssetsController `selectedCurrency` at fetch time. */ export const DEFI_BALANCES_V6_REQUEST_OPTIONS = { includeDeFiBalances: true, forceFetchDeFiPositions: true, includePrices: true, - vsCurrency: 'usd', } as const; -/** - * A single account to query, pairing the CAIP-10 ID sent to the API with the - * internal MetaMask account ID (`InternalAccount.id`) used to key state. - */ -export type DeFiBalanceAccountQuery = { - caipAccountId: CaipAccountId; - internalAccountId: string; -}; - export type DeFiBalancesQuery = { - /** Per-account entries linking CAIP-10 IDs to internal account IDs. */ - accounts: DeFiBalanceAccountQuery[]; - /** CAIP-10 account IDs to query (EVM and/or Solana). */ - accountIds: CaipAccountId[]; /** CAIP-2 networks to query, deduped across accounts. */ networks: CaipChainId[]; + /** + * Normalized CAIP-10 account IDs → internal MetaMask account IDs + * (`InternalAccount.id`). Keys are normalized via + * {@link normalizeCaipAccountId} so they can be sent as the v6 request + * account IDs and used to match response IDs case-insensitively for EVM. + */ + internalAccountIdByCaip: Map; }; /** @@ -92,31 +90,30 @@ export function normalizeCaipAccountId(caipAccountId: string): string { * the client can filter by enabled networks when reading state. * * @param internalAccounts - Accounts belonging to the selected account group. - * @param supportedNetworks - Networks supported by the DeFi balances API. - * @returns Account IDs and networks for the v6 multiaccount balances request. + * @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[], - supportedNetworks: readonly CaipChainId[] = DEFI_SUPPORTED_NETWORKS, ): DeFiBalancesQuery { - const evmNetworks = supportedNetworks.filter((network) => + const evmNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) => network.startsWith(`${KnownCaipNamespace.Eip155}:`), ); - const solanaNetworks = supportedNetworks.filter((network) => + const solanaNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) => network.startsWith(`${KnownCaipNamespace.Solana}:`), ); - const accounts: DeFiBalanceAccountQuery[] = []; const networks: CaipChainId[] = []; + const internalAccountIdByCaip = new Map(); const evmAccount = internalAccounts.find((account) => isEvmAccountType(account.type), ); if (evmAccount && evmNetworks.length > 0) { - accounts.push({ - caipAccountId: toEvmCaipAccountId(evmAccount.address), - internalAccountId: evmAccount.id, - }); + internalAccountIdByCaip.set( + normalizeCaipAccountId(toEvmCaipAccountId(evmAccount.address)), + evmAccount.id, + ); networks.push(...evmNetworks); } @@ -125,21 +122,21 @@ export function buildDeFiBalancesQuery( ); if (solanaAccount && solanaNetworks.length > 0) { const [, solanaReference] = SOLANA_MAINNET_CAIP_CHAIN_ID.split(':'); - - accounts.push({ - caipAccountId: toCaipAccountId( - KnownCaipNamespace.Solana, - solanaReference, - solanaAccount.address, + internalAccountIdByCaip.set( + normalizeCaipAccountId( + toCaipAccountId( + KnownCaipNamespace.Solana, + solanaReference, + solanaAccount.address, + ), ), - internalAccountId: solanaAccount.id, - }); + solanaAccount.id, + ); networks.push(...solanaNetworks); } return { - accounts, - accountIds: accounts.map((account) => account.caipAccountId), networks: [...new Set(networks)] as CaipChainId[], + internalAccountIdByCaip, }; } diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts index 7c35fd80964..b09fe30b106 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts @@ -267,20 +267,29 @@ function finalizeGroup(group: MutableProtocolGroup): DeFiProtocolPositionGroup { * * The v6 response keys accounts by the CAIP-10 ID sent to the API, so * `resolveAccountId` maps that back to the internal MetaMask account ID used to - * key state. It defaults to the identity function (leaving the response ID) for - * callers that don't need the mapping (e.g. tests). + * key state. Accounts that do not resolve are skipped. It defaults to the + * identity function (leaving the response ID) for callers that don't need the + * mapping (e.g. tests). * * @param response - The v6 multiaccount balances response. - * @param resolveAccountId - Maps a response (CAIP-10) account ID to the internal account ID. + * @param resolveAccountId - Maps a response (CAIP-10) account ID to the internal + * account ID, or `undefined` to skip the account. * @returns DeFi positions keyed by internal account ID and chain. */ export function groupDeFiPositionsV6( response: V6BalancesResponse, - resolveAccountId: (responseAccountId: string) => string = (id) => id, + resolveAccountId: ( + responseAccountId: string, + ) => string | undefined = (id) => id, ): DeFiPositionsByAccount { const result: DeFiPositionsByAccount = {}; for (const account of response.accounts) { + const accountId = resolveAccountId(account.accountId); + if (accountId === undefined) { + continue; + } + // Seed every queried account so accounts that no longer hold positions // overwrite (clear) any previously stored data. const groupsByKey = new Map(); @@ -329,9 +338,7 @@ export function groupDeFiPositionsV6( poolGroups.set(poolAddress, poolPositions); } - result[resolveAccountId(account.accountId)] = [...groupsByKey.values()].map( - finalizeGroup, - ); + result[accountId] = [...groupsByKey.values()].map(finalizeGroup); } return result; From fa243223a92ac0d1204a43f227f42f46fec3339f Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 14 Jul 2026 15:20:50 +0100 Subject: [PATCH 05/20] linting --- .../src/DeFiPositionsController/group-defi-positions-v6.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts index b09fe30b106..3c63dcdd2fa 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts @@ -278,9 +278,8 @@ function finalizeGroup(group: MutableProtocolGroup): DeFiProtocolPositionGroup { */ export function groupDeFiPositionsV6( response: V6BalancesResponse, - resolveAccountId: ( - responseAccountId: string, - ) => string | undefined = (id) => id, + resolveAccountId: (responseAccountId: string) => string | undefined = (id) => + id, ): DeFiPositionsByAccount { const result: DeFiPositionsByAccount = {}; From 4e3104f4a3fed51884f6cdefaf9bcc9950cbad1e Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 14 Jul 2026 15:50:13 +0100 Subject: [PATCH 06/20] refactors --- .../DeFiPositionsControllerV2.ts | 1 - .../group-defi-positions-v6.ts | 101 +++++++----------- packages/assets-controllers/src/index.ts | 1 - 3 files changed, 39 insertions(+), 64 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index 948080ad83e..3913b22fa46 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -27,7 +27,6 @@ export type { DeFiPositionsByAccount, DeFiProtocolPositionGroup, DeFiPositionDetailsSection, - DeFiPositionPoolGroup, DeFiUnderlyingPosition, DeFiPositionIconGroupItem, } from './group-defi-positions-v6'; diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts index 3c63dcdd2fa..1cb7c530821 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts @@ -20,7 +20,8 @@ const STATIC_METAMASK_BASE_URL = 'https://static.cx.metamask.io'; * An icon-group entry shown next to a protocol in the DeFi tab list. */ export type DeFiPositionIconGroupItem = { - avatarValue: string; + /** Token icon URL, when one can be built for the asset. */ + avatarValue?: string; symbol: string; }; @@ -32,33 +33,25 @@ export type DeFiUnderlyingPosition = { chainId: CaipChainId; symbol: string; name: string; + decimals: number; /** Raw balance string as returned by the API. */ balance: string; - /** Parsed balance amount, or 0 when invalid. */ - normalizedBalance: number; - decimals: number; - /** Fiat market value in the requested currency. */ - marketValue: number; + /** Fiat market value in the requested currency, when a price is available. */ + marketValue?: number; /** Position type from protocol metadata (e.g. supply, borrow, stake, reward). */ positionType: string; + /** Address of the pool this position belongs to. */ poolAddress: string; - tokenImage: string; -}; - -/** - * A group of underlying positions that share a pool address. - */ -export type DeFiPositionPoolGroup = { - poolAddress: string; - positions: DeFiUnderlyingPosition[]; + /** Token icon URL, when one can be built for the asset. */ + tokenImage?: string; }; /** - * A section of the details page, grouping pools by protocol name. + * A section of the details page, grouping positions by protocol name. */ export type DeFiPositionDetailsSection = { protocolName: string; - poolGroups: DeFiPositionPoolGroup[]; + positions: DeFiUnderlyingPosition[]; }; /** @@ -72,8 +65,6 @@ export type DeFiProtocolPositionGroup = { chainId: CaipChainId; /** Aggregated fiat market value across all positions in the group. */ marketValue: number; - /** Symbols of the underlying tokens, ordered for display. */ - underlyingSymbols: string[]; /** Icon-group entries for the list row. */ iconGroup: DeFiPositionIconGroupItem[]; /** Detail sections consumed by the details page. */ @@ -90,6 +81,8 @@ 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 }; @@ -98,9 +91,9 @@ 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 an empty string when it cannot be built. + * @returns The token icon URL, or `undefined` when it cannot be built. */ -function getDefiTokenImageUrl(assetId: CaipAssetType): string { +function getDefiTokenImageUrl(assetId: CaipAssetType): string | undefined { try { const { chainId } = parseCaipAssetType(assetId); const { namespace } = parseCaipChainId(chainId); @@ -112,7 +105,7 @@ function getDefiTokenImageUrl(assetId: CaipAssetType): string { return `${STATIC_METAMASK_BASE_URL}/api/v2/tokenIcons/assets/${normalizedAssetId}.png`; } catch { - return ''; + return undefined; } } @@ -132,30 +125,23 @@ function isDefiBalanceWithMetadata( ); } -/** - * Returns the parsed balance amount for a v6 balance row. - * - * @param balance - A balance row from the v6 API. - * @returns The parsed balance, or 0 when invalid. - */ -function getNormalizedBalance(balance: V6BalanceItem): number { - const normalizedBalance = Number.parseFloat(balance.balance); - - return Number.isFinite(normalizedBalance) ? normalizedBalance : 0; -} - /** * 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 0 when unavailable. + * @returns The fiat value, or `undefined` when price is missing or the + * balance/price is invalid. */ -function getMarketValue(balance: V6BalanceItem): number { - const normalizedBalance = getNormalizedBalance(balance); - const price = Number.parseFloat(balance.price ?? '0'); +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(price)) { - return 0; + if (!Number.isFinite(normalizedBalance) || !Number.isFinite(price)) { + return undefined; } return normalizedBalance * price; @@ -194,7 +180,7 @@ function toUnderlyingPosition( ): DeFiUnderlyingPosition { const assetId = balance.assetId as CaipAssetType; const { chainId } = parseCaipAssetType(assetId); - const { positionType, poolAddress, protocolIconUrl } = balance.metadata; + const { positionType, poolAddress } = balance.metadata; return { assetId, @@ -202,12 +188,11 @@ function toUnderlyingPosition( symbol: balance.symbol, name: balance.name, balance: balance.balance, - normalizedBalance: getNormalizedBalance(balance), decimals: balance.decimals, marketValue: getMarketValue(balance), positionType, poolAddress, - tokenImage: getDefiTokenImageUrl(assetId) || protocolIconUrl, + tokenImage: getDefiTokenImageUrl(assetId), }; } @@ -222,8 +207,8 @@ type MutableProtocolGroup = { marketValue: number; /** Underlying token symbol -> icon-group entry, deduped by symbol. */ iconBySymbol: Map; - /** protocolName -> (poolAddress -> positions), for details-page sections. */ - sectionByName: Map>; + /** protocolName -> positions, for details-page sections. */ + sectionByName: Map; }; /** @@ -237,12 +222,9 @@ function finalizeGroup(group: MutableProtocolGroup): DeFiProtocolPositionGroup { const sections: DeFiPositionDetailsSection[] = [ ...group.sectionByName.entries(), - ].map(([protocolName, poolGroups]) => ({ + ].map(([protocolName, positions]) => ({ protocolName, - poolGroups: [...poolGroups.entries()].map(([poolAddress, positions]) => ({ - poolAddress, - positions, - })), + positions, })); return { @@ -251,7 +233,6 @@ function finalizeGroup(group: MutableProtocolGroup): DeFiProtocolPositionGroup { protocolIconUrl: group.protocolIconUrl, chainId: group.chainId, marketValue: group.marketValue, - underlyingSymbols: iconGroup.map(({ symbol }) => symbol), iconGroup, sections, }; @@ -300,8 +281,7 @@ export function groupDeFiPositionsV6( const assetId = balance.assetId as CaipAssetType; const { chainId } = parseCaipAssetType(assetId); - const { protocolId, protocolName, protocolIconUrl, poolAddress } = - balance.metadata; + const { protocolId, protocolName, protocolIconUrl } = balance.metadata; const groupKey = `${chainId}#${protocolId}`; const marketValue = getMarketValue(balance); const iconEntry: DeFiPositionIconGroupItem = { @@ -324,17 +304,14 @@ export function groupDeFiPositionsV6( groupsByKey.set(groupKey, group); } - group.marketValue += marketValue; + if (marketValue !== undefined) { + group.marketValue += marketValue; + } group.iconBySymbol.set(balance.symbol, iconEntry); - let poolGroups = group.sectionByName.get(protocolName); - if (!poolGroups) { - poolGroups = new Map(); - group.sectionByName.set(protocolName, poolGroups); - } - const poolPositions = poolGroups.get(poolAddress) ?? []; - poolPositions.push(position); - poolGroups.set(poolAddress, poolPositions); + const sectionPositions = group.sectionByName.get(protocolName) ?? []; + sectionPositions.push(position); + group.sectionByName.set(protocolName, sectionPositions); } result[accountId] = [...groupsByKey.values()].map(finalizeGroup); diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 6f446820409..950c530f77c 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -278,7 +278,6 @@ export type { DeFiPositionsByAccount, DeFiProtocolPositionGroup, DeFiPositionDetailsSection, - DeFiPositionPoolGroup, DeFiUnderlyingPosition, DeFiPositionIconGroupItem, } from './DeFiPositionsController/DeFiPositionsControllerV2'; From f6479932447fc97bfef4bbcd5020dd1508a6d5ae Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 14 Jul 2026 16:07:49 +0100 Subject: [PATCH 07/20] refactor for grouping --- .../group-defi-positions-v6.ts | 120 +++++++----------- 1 file changed, 44 insertions(+), 76 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts index 1cb7c530821..9d190c2b9f5 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts @@ -47,9 +47,13 @@ export type DeFiUnderlyingPosition = { }; /** - * A section of the details page, grouping positions by protocol name. + * A section of the details page, grouping positions that share the same + * API `protocolName`. A single `protocolId` can have multiple names + * (different pools/products under one protocol), so a group may contain + * several sections. */ export type DeFiPositionDetailsSection = { + /** Section label from the API (`metadata.protocolName`). */ protocolName: string; positions: DeFiUnderlyingPosition[]; }; @@ -60,6 +64,7 @@ export type DeFiPositionDetailsSection = { */ export type DeFiProtocolPositionGroup = { protocolId: string; + /** Display name from the first position seen for this protocol. */ protocolName: string; protocolIconUrl: string; chainId: CaipChainId; @@ -67,7 +72,10 @@ export type DeFiProtocolPositionGroup = { marketValue: number; /** Icon-group entries for the list row. */ iconGroup: DeFiPositionIconGroupItem[]; - /** Detail sections consumed by the details page. */ + /** + * Detail sections consumed by the details page, one per distinct API + * `protocolName` under this `protocolId`. + */ sections: DeFiPositionDetailsSection[]; }; @@ -148,25 +156,19 @@ function getMarketValue(balance: V6BalanceItem): number | undefined { } /** - * Moves a priority symbol (ETH/WETH) to the front of the icon group. + * Moves a priority symbol (ETH/WETH) to the front of the icon group, in place. * - * @param iconGroup - The icon-group entries to order. - * @returns The ordered icon-group entries. + * @param iconGroup - The icon-group entries to reorder. */ -function orderIconGroup( - iconGroup: DeFiPositionIconGroupItem[], -): DeFiPositionIconGroupItem[] { - const orderedIcons = [...iconGroup]; - const priorityIndex = orderedIcons.findIndex((item) => +function orderIconGroup(iconGroup: DeFiPositionIconGroupItem[]): void { + const priorityIndex = iconGroup.findIndex((item) => SYMBOL_PRIORITY.includes(item.symbol), ); if (priorityIndex > 0) { - const [priorityIcon] = orderedIcons.splice(priorityIndex, 1); - orderedIcons.unshift(priorityIcon); + const [priorityIcon] = iconGroup.splice(priorityIndex, 1); + iconGroup.unshift(priorityIcon); } - - return orderedIcons; } /** @@ -196,48 +198,6 @@ function toUnderlyingPosition( }; } -/** - * Mutable accumulator used while grouping a single protocol's positions. - */ -type MutableProtocolGroup = { - protocolId: string; - protocolName: string; - protocolIconUrl: string; - chainId: CaipChainId; - marketValue: number; - /** Underlying token symbol -> icon-group entry, deduped by symbol. */ - iconBySymbol: Map; - /** protocolName -> positions, for details-page sections. */ - sectionByName: Map; -}; - -/** - * Finalizes a mutable protocol group into its stored, client-ready shape. - * - * @param group - The accumulated protocol group. - * @returns The finalized protocol position group. - */ -function finalizeGroup(group: MutableProtocolGroup): DeFiProtocolPositionGroup { - const iconGroup = orderIconGroup([...group.iconBySymbol.values()]); - - const sections: DeFiPositionDetailsSection[] = [ - ...group.sectionByName.entries(), - ].map(([protocolName, positions]) => ({ - protocolName, - positions, - })); - - return { - protocolId: group.protocolId, - protocolName: group.protocolName, - protocolIconUrl: group.protocolIconUrl, - chainId: group.chainId, - marketValue: group.marketValue, - iconGroup, - sections, - }; -} - /** * Transforms a v6 multiaccount balances response into the stored DeFi state: * positions keyed by internal account ID, each mapping to a flat list of @@ -272,23 +232,16 @@ export function groupDeFiPositionsV6( // Seed every queried account so accounts that no longer hold positions // overwrite (clear) any previously stored data. - const groupsByKey = new Map(); + const groupsByKey = new Map(); for (const balance of account.balances) { if (!isDefiBalanceWithMetadata(balance)) { continue; } - const assetId = balance.assetId as CaipAssetType; - const { chainId } = parseCaipAssetType(assetId); - const { protocolId, protocolName, protocolIconUrl } = balance.metadata; - const groupKey = `${chainId}#${protocolId}`; - const marketValue = getMarketValue(balance); - const iconEntry: DeFiPositionIconGroupItem = { - symbol: balance.symbol, - avatarValue: getDefiTokenImageUrl(assetId), - }; const position = toUnderlyingPosition(balance); + const { protocolId, protocolName, protocolIconUrl } = balance.metadata; + const groupKey = `${position.chainId}#${protocolId}`; let group = groupsByKey.get(groupKey); if (!group) { @@ -296,25 +249,40 @@ export function groupDeFiPositionsV6( protocolId, protocolName, protocolIconUrl, - chainId, + chainId: position.chainId, marketValue: 0, - iconBySymbol: new Map(), - sectionByName: new Map(), + iconGroup: [], + sections: [], }; groupsByKey.set(groupKey, group); } - if (marketValue !== undefined) { - group.marketValue += marketValue; + if (position.marketValue !== undefined) { + group.marketValue += position.marketValue; + } + + if (!group.iconGroup.some((item) => item.symbol === position.symbol)) { + group.iconGroup.push({ + symbol: position.symbol, + avatarValue: position.tokenImage, + }); } - group.iconBySymbol.set(balance.symbol, iconEntry); - const sectionPositions = group.sectionByName.get(protocolName) ?? []; - sectionPositions.push(position); - group.sectionByName.set(protocolName, sectionPositions); + let section = group.sections.find( + (item) => item.protocolName === protocolName, + ); + if (!section) { + section = { protocolName, positions: [] }; + group.sections.push(section); + } + section.positions.push(position); } - result[accountId] = [...groupsByKey.values()].map(finalizeGroup); + const groups = [...groupsByKey.values()]; + for (const group of groups) { + orderIconGroup(group.iconGroup); + } + result[accountId] = groups; } return result; From 9d7c4358f224a56fad1af26052897f3c41328662 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 14 Jul 2026 16:26:06 +0100 Subject: [PATCH 08/20] rename state field --- .../DeFiPositionsControllerV2.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index 3913b22fa46..080ddb2bcf4 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -39,12 +39,17 @@ export type DeFiPositionsControllerV2State = { * 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. */ - allDeFiPositions: DeFiPositionsByAccount; + allDeFiPositionsV2: DeFiPositionsByAccount; }; const controllerMetadata: StateMetadata = { - allDeFiPositions: { + allDeFiPositionsV2: { includeInStateLogs: false, persist: true, includeInDebugSnapshot: false, @@ -55,7 +60,7 @@ const controllerMetadata: StateMetadata = { export const getDefaultDeFiPositionsControllerV2State = (): DeFiPositionsControllerV2State => { return { - allDeFiPositions: {}, + allDeFiPositionsV2: {}, }; }; @@ -247,7 +252,7 @@ export class DeFiPositionsControllerV2 extends BaseController< for (const [accountId, positions] of Object.entries( positionsByAccount, )) { - state.allDeFiPositions[accountId] = positions; + state.allDeFiPositionsV2[accountId] = positions; } }); } catch (error) { From 8a2695555aa2a558e505a60f1913765efc2ebf4f Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 14 Jul 2026 16:30:20 +0100 Subject: [PATCH 09/20] tests --- .../DeFiPositionsControllerV2.test.ts | 482 ++++++++++++++++++ .../build-defi-balances-query.test.ts | 182 +++++++ .../group-defi-positions-v6.test.ts | 415 +++++++++++++++ 3 files changed, 1079 insertions(+) create mode 100644 packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts create mode 100644 packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.test.ts create mode 100644 packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.test.ts 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..6f999f40a57 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -0,0 +1,482 @@ +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_BALANCES_V6_REQUEST_OPTIONS, + 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', + name: 'mock-sol-snap', + enabled: true, + }, + }, + }, +]; + +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', + protocolName: 'Aave V3', + description: 'Aave V3 on ethereum', + protocolUrl: 'https://aave.com/', + protocolIconUrl: 'https://example.com/aave.png', + positionType: 'supply', + poolAddress: '0xpool', + }, + }, + ], + }, + ], + ...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.minimumFetchIntervalMs - Minimum fetch interval. + * @param config.mockGroupAccounts - Accounts returned for the selected group. + * @param config.mockFetchV6MultiAccountBalances - Mock API fetch function. + * @param config.state - Initial controller state. + * @returns The controller instance and mocks. + */ +function setupController({ + isEnabled = () => true, + getVsCurrency = () => 'USD', + minimumFetchIntervalMs, + mockGroupAccounts = GROUP_ACCOUNTS, + mockFetchV6MultiAccountBalances = jest + .fn() + .mockResolvedValue(buildMockBalancesResponse()), + state, +}: { + isEnabled?: () => boolean; + getVsCurrency?: () => string; + minimumFetchIntervalMs?: number; + mockGroupAccounts?: InternalAccount[]; + mockFetchV6MultiAccountBalances?: jest.Mock; + state?: Partial< + ReturnType + >; +} = {}) { + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + messenger.registerActionHandler( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + () => 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, + minimumFetchIntervalMs, + state, + }); + + return { + controller, + controllerMessenger, + mockFetchV6MultiAccountBalances, + }; +} + +describe('DeFiPositionsControllerV2', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + 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:'), + ), + ...DEFI_BALANCES_V6_REQUEST_OPTIONS, + vsCurrency: 'usd', + }, + ); + + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( + 1, + ); + expect( + controller.state.allDeFiPositionsV2['evm-account-id'][0], + ).toMatchObject({ + protocolId: 'aave-v3', + protocolName: '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', + protocolName: 'Aave V3', + description: 'Aave V3 on ethereum', + protocolUrl: 'https://aave.com/', + protocolIconUrl: 'https://example.com/aave.png', + positionType: 'supply', + poolAddress: '0xpool', + }, + }, + ], + }, + ], + }), + ); + + 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], + ...DEFI_BALANCES_V6_REQUEST_OPTIONS, + vsCurrency: 'usd', + }, + ); + expect(controller.state.allDeFiPositionsV2).toStrictEqual({ + 'evm-account-id': [], + 'solana-account-id': [], + }); + }); + + it('throttles repeated fetches for the same accounts within the interval', async () => { + const { controller, mockFetchV6MultiAccountBalances } = setupController({ + minimumFetchIntervalMs: 60_000, + }); + + await controller.fetchDeFiPositions(); + await controller.fetchDeFiPositions(); + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(60_000); + await controller.fetchDeFiPositions(); + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + }); + + it('clears the throttle claim when a fetch fails so retries are allowed', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const mockFetchV6MultiAccountBalances = jest + .fn() + .mockRejectedValueOnce(new Error('network error')) + .mockResolvedValueOnce(buildMockBalancesResponse()); + + const { controller } = setupController({ + mockFetchV6MultiAccountBalances, + }); + + await controller.fetchDeFiPositions(); + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(1); + expect(controller.state.allDeFiPositionsV2).toStrictEqual({}); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch DeFi positions', + expect.any(Error), + ); + + await controller.fetchDeFiPositions(); + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( + 1, + ); + }); + + 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/build-defi-balances-query.test.ts b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.test.ts new file mode 100644 index 00000000000..4ff039434b9 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.test.ts @@ -0,0 +1,182 @@ +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_BALANCES_V6_REQUEST_OPTIONS, + DEFI_SUPPORTED_NETWORKS, + normalizeCaipAccountId, +} 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('normalizeCaipAccountId', () => { + it('lowercases EVM CAIP account IDs', () => { + expect( + normalizeCaipAccountId(`eip155:0:${EVM_ADDRESS.toUpperCase()}`), + ).toBe(`eip155:0:${EVM_ADDRESS.toLowerCase()}`); + }); + + it('leaves non-EVM CAIP account IDs unchanged', () => { + const solanaCaipAccountId = `solana:${SolScope.Mainnet.split(':')[1]}:${SOLANA_ADDRESS}`; + + expect(normalizeCaipAccountId(solanaCaipAccountId)).toBe( + solanaCaipAccountId, + ); + }); +}); + +describe('buildDeFiBalancesQuery', () => { + it('exports the fixed v6 request options used by the controller', () => { + expect(DEFI_BALANCES_V6_REQUEST_OPTIONS).toStrictEqual({ + includeDeFiBalances: true, + forceFetchDeFiPositions: true, + includePrices: true, + }); + }); + + it('builds an EVM CAIP account spanning all supported EVM networks', () => { + const result = buildDeFiBalancesQuery([mockEvmAccount, 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/group-defi-positions-v6.test.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.test.ts new file mode 100644 index 00000000000..dacc6205d6d --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.test.ts @@ -0,0 +1,415 @@ +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', + protocolName: 'Aave V3', + description: 'Aave V3 on ethereum', + protocolUrl: 'https://aave.com/', + protocolIconUrl: 'https://example.com/aave.png', + positionType: 'supply', + poolAddress: '0xpool', +}; + +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: 'supply', + 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', + protocolName: '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('skips accounts that do not resolve to an internal account ID', () => { + const response = buildResponse([ + { + accountId: 'eip155:0:0xunknown', + balances: [ + { + category: 'defi', + assetId: WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '1', + price: '2000', + metadata: AAVE_METADATA, + }, + ], + }, + { + accountId: 'eip155:0: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, (responseAccountId) => + responseAccountId === 'eip155:0:0xknown' ? 'internal-1' : undefined, + ); + + 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, + protocolName: 'Aave V3 USDC', + }, + }, + { + category: 'defi', + assetId: USDT_ASSET_ID, + name: 'Tether', + symbol: 'USDT', + decimals: 6, + balance: '5', + price: '1', + metadata: { + ...AAVE_METADATA, + protocolName: '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('creates separate detail sections per protocolName 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, + protocolName: 'Aave V3 Supply', + }, + }, + { + category: 'defi', + assetId: USDC_ASSET_ID, + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + balance: '100', + price: '1', + metadata: { + ...AAVE_METADATA, + protocolName: 'Aave V3 Borrow', + positionType: 'borrow', + }, + }, + ], + }, + ]); + + const [group] = groupDeFiPositionsV6(response)['account-1']; + + expect(group.protocolName).toBe('Aave V3 Supply'); + expect(group.sections).toStrictEqual([ + { + protocolName: 'Aave V3 Supply', + positions: [ + expect.objectContaining({ + symbol: 'WETH', + positionType: 'supply', + }), + ], + }, + { + protocolName: 'Aave V3 Borrow', + positions: [ + expect.objectContaining({ + symbol: 'USDC', + positionType: 'borrow', + }), + ], + }, + ]); + }); + + 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: 'reward', + }, + }, + ], + }, + ]); + + 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: 'supply', + poolAddress: '0xpool', + tokenImage: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2.png', + }); + }); +}); From 057ff96708b6c8aaaa53228f689fc5a13bd6ecd5 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 14 Jul 2026 16:30:40 +0100 Subject: [PATCH 10/20] lint --- .../DeFiPositionsControllerV2.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts index 6f999f40a57..e78aee5a205 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -1,5 +1,8 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import type { ApiPlatformClient, V6BalancesResponse } from '@metamask/core-backend'; +import type { + ApiPlatformClient, + V6BalancesResponse, +} from '@metamask/core-backend'; import { BtcAccountType, EthAccountType, @@ -102,7 +105,8 @@ function buildMockBalancesResponse( balances: [ { category: 'defi', - assetId: 'eip155:1/erc20:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + assetId: + 'eip155:1/erc20:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', name: 'Wrapped Ether', symbol: 'WETH', decimals: 18, @@ -152,9 +156,7 @@ function setupController({ minimumFetchIntervalMs?: number; mockGroupAccounts?: InternalAccount[]; mockFetchV6MultiAccountBalances?: jest.Mock; - state?: Partial< - ReturnType - >; + state?: Partial>; } = {}) { const messenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, @@ -313,7 +315,9 @@ describe('DeFiPositionsControllerV2', () => { await controller.fetchDeFiPositions(); - expect(controller.state.allDeFiPositionsV2).toHaveProperty('evm-account-id'); + expect(controller.state.allDeFiPositionsV2).toHaveProperty( + 'evm-account-id', + ); expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( 1, ); From f8cde807eef217189ff340159a02d8e8547f0458 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 14 Jul 2026 16:45:29 +0100 Subject: [PATCH 11/20] changelog --- packages/assets-controllers/CHANGELOG.md | 4 ++++ .../DeFiPositionsControllerV2-method-action-types.ts | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index fc7a60d3c7d..0b0432081cc 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,6 +9,10 @@ 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 `getDefaultDeFiPositionsControllerV2State` + - Export types including `DeFiPositionsControllerV2State`, messenger/action/event types, `DeFiPositionsByAccount`, and related position group types + - Export `DeFiPositionsControllerV2FetchDeFiPositionsAction` and `buildDeFiBalancesQuery` helpers - Add Robinhood Chain (`4663`/`0x1237`) BalanceFetcher support for legacy single-call token detection ([#9473](https://github.com/MetaMask/core/pull/9473)) - Add `Robinhood` in `SupportedTokenDetectionNetworks` - Add Robinhood BalanceFetcher address in `SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID` diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts index ebc596f4e98..1077d1da23c 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts @@ -12,8 +12,8 @@ import type { DeFiPositionsControllerV2 } from './DeFiPositionsControllerV2'; * response, and updating state. * * Throttled per set of accounts by an in-memory minimum interval, so repeated - * calls within the window are no-ops. Disabled controllers and empty account - * groups return without fetching. + * calls within the window are no-ops (no HTTP, no regroup, no state write). + * Disabled controllers and empty account groups return without fetching. */ export type DeFiPositionsControllerV2FetchDeFiPositionsAction = { type: `DeFiPositionsControllerV2:fetchDeFiPositions`; From 63a75da3435a039ea36c0a9daf3d8cb1d8a17cef Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 14 Jul 2026 17:49:06 +0100 Subject: [PATCH 12/20] refactors --- .../DeFiPositionsControllerV2.ts | 16 +++++++-- .../build-defi-balances-query.ts | 36 ++++++++++++++++++- .../group-defi-positions-v6.ts | 18 ++++++++-- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index 080ddb2bcf4..0f05cd7cb27 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -11,7 +11,7 @@ import type { Messenger } from '@metamask/messenger'; import { buildDeFiBalancesQuery, DEFI_BALANCES_V6_REQUEST_OPTIONS, - normalizeCaipAccountId, + toAccountMatchKey, } from './build-defi-balances-query'; import type { DeFiPositionsControllerV2MethodActions } from './DeFiPositionsControllerV2-method-action-types'; import type { DeFiPositionsByAccount } from './group-defi-positions-v6'; @@ -236,12 +236,22 @@ export class DeFiPositionsControllerV2 extends BaseController< vsCurrency: this.#getVsCurrency().toLowerCase(), }); - // The v6 response echoes the CAIP-10 IDs we sent; map them back to the + // The v6 response echoes a per-chain CAIP-10 ID for every chain + // (`eip155:1:`, `eip155:137:`, ...), while we requested with + // the all-chains reference (`eip155:0:`). Match on namespace + + // address (ignoring the chain reference) so responses map back to the // internal account IDs used to key state. Unmatched accounts are skipped. + const internalAccountIdByMatchKey = new Map(); + for (const [caipAccountId, internalId] of internalAccountIdByCaip) { + internalAccountIdByMatchKey.set( + toAccountMatchKey(caipAccountId), + internalId, + ); + } const resolveAccountId = ( responseAccountId: string, ): string | undefined => - internalAccountIdByCaip.get(normalizeCaipAccountId(responseAccountId)); + internalAccountIdByMatchKey.get(toAccountMatchKey(responseAccountId)); const positionsByAccount = groupDeFiPositionsV6( response, diff --git a/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts index 1c303b3c3ea..a0f588be467 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts @@ -5,7 +5,11 @@ import { } 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'; +import { + KnownCaipNamespace, + parseCaipAccountId, + toCaipAccountId, +} from '@metamask/utils'; /** * Networks the DeFi balances (v6 multiaccount) endpoint supports. @@ -80,6 +84,36 @@ export function normalizeCaipAccountId(caipAccountId: string): string { : caipAccountId; } +/** + * Builds a chain-reference-agnostic key (`namespace:address`) for matching the + * CAIP-10 account IDs we send 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 (case-insensitive); + * other namespaces (e.g. Solana) keep their case. + * + * @param caipAccountId - A CAIP-10 account ID. + * @returns The match key, or the normalized ID if it cannot be parsed. + */ +export 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 normalizeCaipAccountId(caipAccountId); + } +} + /** * Builds the account IDs and networks to request DeFi positions for, from the * accounts in the selected account group. diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts index 9d190c2b9f5..7bb8b5a179b 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts @@ -222,7 +222,14 @@ export function groupDeFiPositionsV6( resolveAccountId: (responseAccountId: string) => string | undefined = (id) => id, ): DeFiPositionsByAccount { - const result: DeFiPositionsByAccount = {}; + // 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 = resolveAccountId(account.accountId); @@ -232,7 +239,11 @@ export function groupDeFiPositionsV6( // Seed every queried account so accounts that no longer hold positions // overwrite (clear) any previously stored data. - const groupsByKey = new Map(); + let groupsByKey = groupsByAccountKey.get(accountId); + if (!groupsByKey) { + groupsByKey = new Map(); + groupsByAccountKey.set(accountId, groupsByKey); + } for (const balance of account.balances) { if (!isDefiBalanceWithMetadata(balance)) { @@ -277,7 +288,10 @@ export function groupDeFiPositionsV6( } 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); From 06e53d3315b5ecdc2da2944db5194cd63fbd684d Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 14 Jul 2026 17:49:26 +0100 Subject: [PATCH 13/20] use fake account --- .../DeFiPositionsControllerV2.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index 0f05cd7cb27..68e9e001333 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -11,6 +11,7 @@ import type { Messenger } from '@metamask/messenger'; import { buildDeFiBalancesQuery, DEFI_BALANCES_V6_REQUEST_OPTIONS, + normalizeCaipAccountId, toAccountMatchKey, } from './build-defi-balances-query'; import type { DeFiPositionsControllerV2MethodActions } from './DeFiPositionsControllerV2-method-action-types'; @@ -207,6 +208,17 @@ export class DeFiPositionsControllerV2 extends BaseController< return; } + // TEMPORARY: always fetch this account for testing, regardless of selection. + // Positions are still stored under the selected account's internal ID. + const TEST_CAIP_ACCOUNT_ID = + 'eip155:0:0x3e8734ec146c981e3ed1f6b582d447dde701d90c'; + const selectedInternalAccountId = [...internalAccountIdByCaip.values()][0]; + internalAccountIdByCaip.clear(); + internalAccountIdByCaip.set( + normalizeCaipAccountId(TEST_CAIP_ACCOUNT_ID), + selectedInternalAccountId, + ); + const accountIds = [...internalAccountIdByCaip.keys()]; // Stable key so the same account set throttles together regardless of map // iteration order. From 72c07fa30d450b3a16d5c710c5d87f3426064231 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 14 Jul 2026 17:51:13 +0100 Subject: [PATCH 14/20] linting --- .../src/DeFiPositionsController/build-defi-balances-query.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts index a0f588be467..5d35c1a2a4b 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts @@ -105,9 +105,7 @@ export function toAccountMatchKey(caipAccountId: string): string { address, } = parseCaipAccountId(caipAccountId as CaipAccountId); const normalizedAddress = - namespace === KnownCaipNamespace.Eip155 - ? address.toLowerCase() - : address; + namespace === KnownCaipNamespace.Eip155 ? address.toLowerCase() : address; return `${namespace}:${normalizedAddress}`; } catch { return normalizeCaipAccountId(caipAccountId); From c3e3b0bc416df10486494420b1cfd8bd597afc7c Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 14 Jul 2026 18:06:37 +0100 Subject: [PATCH 15/20] position types --- packages/assets-controllers/CHANGELOG.md | 1 + .../DeFiPositionsControllerV2.test.ts | 4 +- .../DeFiPositionsControllerV2.ts | 5 ++ .../group-defi-positions-v6.test.ts | 59 ++++++++++++++++--- .../group-defi-positions-v6.ts | 44 ++++++++++++-- packages/assets-controllers/src/index.ts | 3 + packages/core-backend/CHANGELOG.md | 4 ++ .../core-backend/src/api/accounts/index.ts | 2 + .../core-backend/src/api/accounts/types.ts | 27 ++++++++- packages/core-backend/src/api/index.ts | 3 +- packages/core-backend/src/index.ts | 2 + 11 files changed, 139 insertions(+), 15 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 0b0432081cc..1f7d0ece40e 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 `getDefaultDeFiPositionsControllerV2State` - Export types including `DeFiPositionsControllerV2State`, messenger/action/event types, `DeFiPositionsByAccount`, and related position group types + - Export `DeFiPositionType`, `DEFI_POSITION_TYPES`, and `DEFI_POSITION_LIABILITY_TYPES` (`lending` is subtracted from protocol `marketValue`) - Export `DeFiPositionsControllerV2FetchDeFiPositionsAction` and `buildDeFiBalancesQuery` helpers - Add Robinhood Chain (`4663`/`0x1237`) BalanceFetcher support for legacy single-call token detection ([#9473](https://github.com/MetaMask/core/pull/9473)) - Add `Robinhood` in `SupportedTokenDetectionNetworks` diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts index e78aee5a205..387d9d4cf21 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -118,7 +118,7 @@ function buildMockBalancesResponse( description: 'Aave V3 on ethereum', protocolUrl: 'https://aave.com/', protocolIconUrl: 'https://example.com/aave.png', - positionType: 'supply', + positionType: 'deposit', poolAddress: '0xpool', }, }, @@ -299,7 +299,7 @@ describe('DeFiPositionsControllerV2', () => { description: 'Aave V3 on ethereum', protocolUrl: 'https://aave.com/', protocolIconUrl: 'https://example.com/aave.png', - positionType: 'supply', + positionType: 'deposit', poolAddress: '0xpool', }, }, diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index 68e9e001333..e271bf63fcc 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -30,6 +30,11 @@ export type { DeFiPositionDetailsSection, DeFiUnderlyingPosition, DeFiPositionIconGroupItem, + DeFiPositionType, +} from './group-defi-positions-v6'; +export { + DEFI_POSITION_TYPES, + DEFI_POSITION_LIABILITY_TYPES, } from './group-defi-positions-v6'; export type DeFiPositionsControllerV2State = { 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 index dacc6205d6d..18581a88541 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.test.ts @@ -9,7 +9,7 @@ const AAVE_METADATA = { description: 'Aave V3 on ethereum', protocolUrl: 'https://aave.com/', protocolIconUrl: 'https://example.com/aave.png', - positionType: 'supply', + positionType: 'deposit', poolAddress: '0xpool', }; @@ -64,7 +64,7 @@ describe('groupDeFiPositionsV6', () => { price: '1', metadata: { ...AAVE_METADATA, - positionType: 'supply', + positionType: 'deposit', poolAddress: '0xpool2', }, }, @@ -262,6 +262,51 @@ describe('groupDeFiPositionsV6', () => { 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, + protocolName: '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, + protocolName: '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 protocolName under one protocolId', () => { const response = buildResponse([ { @@ -291,7 +336,7 @@ describe('groupDeFiPositionsV6', () => { metadata: { ...AAVE_METADATA, protocolName: 'Aave V3 Borrow', - positionType: 'borrow', + positionType: 'lending', }, }, ], @@ -307,7 +352,7 @@ describe('groupDeFiPositionsV6', () => { positions: [ expect.objectContaining({ symbol: 'WETH', - positionType: 'supply', + positionType: 'deposit', }), ], }, @@ -316,7 +361,7 @@ describe('groupDeFiPositionsV6', () => { positions: [ expect.objectContaining({ symbol: 'USDC', - positionType: 'borrow', + positionType: 'lending', }), ], }, @@ -358,7 +403,7 @@ describe('groupDeFiPositionsV6', () => { price: '2000', metadata: { ...AAVE_METADATA, - positionType: 'reward', + positionType: 'rewards', }, }, ], @@ -406,7 +451,7 @@ describe('groupDeFiPositionsV6', () => { decimals: 18, balance: '1.5', marketValue: 3000, - positionType: 'supply', + positionType: 'deposit', poolAddress: '0xpool', tokenImage: 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2.png', diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts index 7bb8b5a179b..e3a7f9cbb24 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts @@ -1,7 +1,9 @@ +import { V6_DEFI_POSITION_TYPES } from '@metamask/core-backend'; import type { V6BalanceItem, V6BalanceMetadata, V6BalancesResponse, + V6DeFiPositionType, } from '@metamask/core-backend'; import type { CaipAssetType, CaipChainId } from '@metamask/utils'; import { @@ -16,6 +18,25 @@ import { // with a shared helper if/when one lands in core. 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. */ @@ -38,8 +59,8 @@ export type DeFiUnderlyingPosition = { balance: string; /** Fiat market value in the requested currency, when a price is available. */ marketValue?: number; - /** Position type from protocol metadata (e.g. supply, borrow, stake, reward). */ - positionType: string; + /** Position type from protocol metadata. */ + positionType: DeFiPositionType; /** Address of the pool this position belongs to. */ poolAddress: string; /** Token icon URL, when one can be built for the asset. */ @@ -68,7 +89,10 @@ export type DeFiProtocolPositionGroup = { protocolName: string; protocolIconUrl: string; chainId: CaipChainId; - /** Aggregated fiat market value across all positions in the group. */ + /** + * 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[]; @@ -155,6 +179,17 @@ function getMarketValue(balance: V6BalanceItem): number | 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. * @@ -269,7 +304,8 @@ export function groupDeFiPositionsV6( } if (position.marketValue !== undefined) { - group.marketValue += position.marketValue; + group.marketValue += + position.marketValue * getMarketValueSign(position.positionType); } if (!group.iconGroup.some((item) => item.symbol === position.symbol)) { diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 950c530f77c..c0f9e86f6a8 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -267,6 +267,8 @@ export type { GroupedDeFiPositions } from './DeFiPositionsController/group-defi- export { DeFiPositionsControllerV2, getDefaultDeFiPositionsControllerV2State, + DEFI_POSITION_TYPES, + DEFI_POSITION_LIABILITY_TYPES, } from './DeFiPositionsController/DeFiPositionsControllerV2'; export type { DeFiPositionsControllerV2State, @@ -280,6 +282,7 @@ export type { DeFiPositionDetailsSection, DeFiUnderlyingPosition, DeFiPositionIconGroupItem, + DeFiPositionType, } from './DeFiPositionsController/DeFiPositionsControllerV2'; export type { DeFiPositionsControllerV2FetchDeFiPositionsAction } from './DeFiPositionsController/DeFiPositionsControllerV2-method-action-types'; export { diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index c0e87c8bada..c8467ac734d 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Export `V6_DEFI_POSITION_TYPES` and inferred `V6DeFiPositionType`, and type `V6BalanceMetadata.positionType` with the Accounts API v6 DeFi position module values (`deposit`, `lending`, `yield`, `liquidity_pool`, `staked`, `leveraged_farming`, `nft_staked`, `farming`, `locked`, `vesting`, `rewards`, `investment`) + ### Changed - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) diff --git a/packages/core-backend/src/api/accounts/index.ts b/packages/core-backend/src/api/accounts/index.ts index 3f08819c3fa..1242b951f21 100644 --- a/packages/core-backend/src/api/accounts/index.ts +++ b/packages/core-backend/src/api/accounts/index.ts @@ -3,6 +3,7 @@ */ export { AccountsApiClient } from './client'; +export { V6_DEFI_POSITION_TYPES } from './types'; export type { V5BalanceItem, V5BalancesResponse, @@ -10,6 +11,7 @@ export type { V2BalancesResponse, V4BalancesResponse, V6VsCurrency, + V6DeFiPositionType, V6BalanceMetadata, V6TokenMetadata, V6BalanceItem, diff --git a/packages/core-backend/src/api/accounts/types.ts b/packages/core-backend/src/api/accounts/types.ts index 41eeb031767..322477e198a 100644 --- a/packages/core-backend/src/api/accounts/types.ts +++ b/packages/core-backend/src/api/accounts/types.ts @@ -63,6 +63,31 @@ export type V4BalancesResponse = { */ export type V6VsCurrency = string; +/** + * Possible `positionType` values on DeFi rows in the v6 balances response. + * Categorizes the protocol module where the position is held. + */ +export const V6_DEFI_POSITION_TYPES = [ + 'deposit', + 'lending', + 'yield', + 'liquidity_pool', + 'staked', + 'leveraged_farming', + 'nft_staked', + 'farming', + 'locked', + 'vesting', + 'rewards', + 'investment', +] as const; + +/** + * The specific module or functionality within a DeFi protocol where a position + * is held. + */ +export type V6DeFiPositionType = (typeof V6_DEFI_POSITION_TYPES)[number]; + /** * DeFi protocol metadata attached to a `category: defi` row in the v6 balances * response (`BalanceMetadataV3ResponseDto`). @@ -73,7 +98,7 @@ export type V6BalanceMetadata = { description: string; protocolUrl: string; protocolIconUrl: string; - positionType: string; + positionType: V6DeFiPositionType; poolAddress: string; }; diff --git a/packages/core-backend/src/api/index.ts b/packages/core-backend/src/api/index.ts index 02c01b9838e..9f30f023979 100644 --- a/packages/core-backend/src/api/index.ts +++ b/packages/core-backend/src/api/index.ts @@ -23,7 +23,7 @@ export { } from './shared-types'; // Accounts API -export { AccountsApiClient } from './accounts'; +export { AccountsApiClient, V6_DEFI_POSITION_TYPES } from './accounts'; export type { V5BalanceItem, V5BalancesResponse, @@ -31,6 +31,7 @@ export type { V2BalancesResponse, V4BalancesResponse, V6VsCurrency, + V6DeFiPositionType, V6BalanceMetadata, V6TokenMetadata, V6BalanceItem, diff --git a/packages/core-backend/src/index.ts b/packages/core-backend/src/index.ts index f88d719373e..b58ac3abc54 100644 --- a/packages/core-backend/src/index.ts +++ b/packages/core-backend/src/index.ts @@ -121,6 +121,7 @@ export { API_URLS, STALE_TIMES, GC_TIMES, + V6_DEFI_POSITION_TYPES, // Helpers calculateRetryDelay, getQueryOptionsOverrides, @@ -148,6 +149,7 @@ export type { V2BalancesResponse, V4BalancesResponse, V6VsCurrency, + V6DeFiPositionType, V6BalanceMetadata, V6TokenMetadata, V6BalanceItem, From f6587f69d30e1c764bbc8dfc5962910faa803b26 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 14 Jul 2026 18:08:15 +0100 Subject: [PATCH 16/20] fix tests --- .../DeFiPositionsControllerV2.test.ts | 17 ++++++++++++----- .../DeFiPositionsControllerV2.ts | 12 ------------ 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts index 387d9d4cf21..931cf6103a3 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -61,8 +61,6 @@ const GROUP_ACCOUNTS_WITH_SOLANA: InternalAccount[] = [ lastSelected: Date.now(), snap: { id: 'mock-sol-snap', - name: 'mock-sol-snap', - enabled: true, }, }, }, @@ -142,8 +140,8 @@ function buildMockBalancesResponse( * @returns The controller instance and mocks. */ function setupController({ - isEnabled = () => true, - getVsCurrency = () => 'USD', + isEnabled = (): boolean => true, + getVsCurrency = (): string => 'USD', minimumFetchIntervalMs, mockGroupAccounts = GROUP_ACCOUNTS, mockFetchV6MultiAccountBalances = jest @@ -157,7 +155,16 @@ function setupController({ mockGroupAccounts?: 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, }); diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index e271bf63fcc..71066947830 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -11,7 +11,6 @@ import type { Messenger } from '@metamask/messenger'; import { buildDeFiBalancesQuery, DEFI_BALANCES_V6_REQUEST_OPTIONS, - normalizeCaipAccountId, toAccountMatchKey, } from './build-defi-balances-query'; import type { DeFiPositionsControllerV2MethodActions } from './DeFiPositionsControllerV2-method-action-types'; @@ -213,17 +212,6 @@ export class DeFiPositionsControllerV2 extends BaseController< return; } - // TEMPORARY: always fetch this account for testing, regardless of selection. - // Positions are still stored under the selected account's internal ID. - const TEST_CAIP_ACCOUNT_ID = - 'eip155:0:0x3e8734ec146c981e3ed1f6b582d447dde701d90c'; - const selectedInternalAccountId = [...internalAccountIdByCaip.values()][0]; - internalAccountIdByCaip.clear(); - internalAccountIdByCaip.set( - normalizeCaipAccountId(TEST_CAIP_ACCOUNT_ID), - selectedInternalAccountId, - ); - const accountIds = [...internalAccountIdByCaip.keys()]; // Stable key so the same account set throttles together regardless of map // iteration order. From 8770d266b1928cdb6623f21553a06129e61f4853 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Wed, 15 Jul 2026 09:47:38 +0100 Subject: [PATCH 17/20] changelog --- packages/core-backend/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index c8467ac734d..ca5ca5f8bae 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Export `V6_DEFI_POSITION_TYPES` and inferred `V6DeFiPositionType`, and type `V6BalanceMetadata.positionType` with the Accounts API v6 DeFi position module values (`deposit`, `lending`, `yield`, `liquidity_pool`, `staked`, `leveraged_farming`, `nft_staked`, `farming`, `locked`, `vesting`, `rewards`, `investment`) +- Export `V6_DEFI_POSITION_TYPES` and inferred `V6DeFiPositionType`, and type `V6BalanceMetadata.positionType` with the Accounts API v6 DeFi position module values (`deposit`, `lending`, `yield`, `liquidity_pool`, `staked`, `leveraged_farming`, `nft_staked`, `farming`, `locked`, `vesting`, `rewards`, `investment`) ([#9503](https://github.com/MetaMask/core/pull/9503)) ### Changed From 72f0609be8e04ce5c1c2f7cfa90e6b05293e7979 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Thu, 16 Jul 2026 14:01:00 +0100 Subject: [PATCH 18/20] fixes to types --- packages/assets-controllers/CHANGELOG.md | 1 + .../DeFiPositionsControllerV2.test.ts | 8 +- .../defi-visualizer.html | 4 +- .../group-defi-positions-v6.test.ts | 74 ++++++++++++++++--- .../group-defi-positions-v6.ts | 36 ++++++--- packages/core-backend/CHANGELOG.md | 2 + .../src/api/accounts/client.test.ts | 3 +- .../core-backend/src/api/accounts/types.ts | 3 +- 8 files changed, 100 insertions(+), 31 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 1f7d0ece40e..8fd4487c8ae 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 `getDefaultDeFiPositionsControllerV2State` - Export types including `DeFiPositionsControllerV2State`, messenger/action/event types, `DeFiPositionsByAccount`, and related position group types + - Group detail sections by Accounts API `productName`; keep per-position `groupId` from metadata - Export `DeFiPositionType`, `DEFI_POSITION_TYPES`, and `DEFI_POSITION_LIABILITY_TYPES` (`lending` is subtracted from protocol `marketValue`) - Export `DeFiPositionsControllerV2FetchDeFiPositionsAction` and `buildDeFiBalancesQuery` helpers - Add Robinhood Chain (`4663`/`0x1237`) BalanceFetcher support for legacy single-call token detection ([#9473](https://github.com/MetaMask/core/pull/9473)) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts index 931cf6103a3..f8bfd293f21 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -112,12 +112,13 @@ function buildMockBalancesResponse( price: '2000', metadata: { protocolId: 'aave-v3', - protocolName: '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', }, }, ], @@ -278,7 +279,7 @@ describe('DeFiPositionsControllerV2', () => { controller.state.allDeFiPositionsV2['evm-account-id'][0], ).toMatchObject({ protocolId: 'aave-v3', - protocolName: 'Aave V3', + productName: 'Aave V3', chainId: 'eip155:1', marketValue: 2000, }); @@ -302,12 +303,13 @@ describe('DeFiPositionsControllerV2', () => { price: '2000', metadata: { protocolId: 'aave-v3', - protocolName: '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', }, }, ], diff --git a/packages/assets-controllers/src/DeFiPositionsController/defi-visualizer.html b/packages/assets-controllers/src/DeFiPositionsController/defi-visualizer.html index 20eca47f1cd..ccf6b204982 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/defi-visualizer.html +++ b/packages/assets-controllers/src/DeFiPositionsController/defi-visualizer.html @@ -504,12 +504,12 @@

Raw JSON response

var NO_POOL = '(no pool address)'; function group(entries) { - // protocolId -> protocolName -> poolAddress -> positionType -> [entries] + // protocolId -> productName -> poolAddress -> positionType -> [entries] var tree = {}; entries.forEach(function (e) { var m = e.raw.metadata || {}; var pid = m.protocolId || '(no protocolId)'; - var pname = m.protocolName || '(no protocolName)'; + var pname = m.productName || '(no productName)'; var pool = m.poolAddress || NO_POOL; var ptype = m.positionType || '(no positionType)'; tree[pid] = tree[pid] || {}; 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 index 18581a88541..de095116137 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.test.ts @@ -5,12 +5,13 @@ import { groupDeFiPositionsV6 } from './group-defi-positions-v6'; const AAVE_METADATA = { protocolId: 'aave-v3', - protocolName: '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 = @@ -96,7 +97,7 @@ describe('groupDeFiPositionsV6', () => { expect(ethGroup).toMatchObject({ protocolId: 'aave-v3', - protocolName: 'Aave V3', + productName: 'Aave V3', protocolIconUrl: 'https://example.com/aave.png', chainId: 'eip155:1', marketValue: 2100, @@ -234,7 +235,7 @@ describe('groupDeFiPositionsV6', () => { price: '1', metadata: { ...AAVE_METADATA, - protocolName: 'Aave V3 USDC', + productName: 'Aave V3 USDC', }, }, { @@ -247,7 +248,7 @@ describe('groupDeFiPositionsV6', () => { price: '1', metadata: { ...AAVE_METADATA, - protocolName: 'Aave V3 USDT', + productName: 'Aave V3 USDT', }, }, ], @@ -277,7 +278,7 @@ describe('groupDeFiPositionsV6', () => { price: '2000', metadata: { ...AAVE_METADATA, - protocolName: 'Aave V3 Supply', + productName: 'Aave V3 Supply', positionType: 'deposit', }, }, @@ -291,7 +292,7 @@ describe('groupDeFiPositionsV6', () => { price: '1', metadata: { ...AAVE_METADATA, - protocolName: 'Aave V3 Borrow', + productName: 'Aave V3 Borrow', positionType: 'lending', }, }, @@ -307,7 +308,7 @@ describe('groupDeFiPositionsV6', () => { expect(group.sections[1].positions[0].positionType).toBe('lending'); }); - it('creates separate detail sections per protocolName under one protocolId', () => { + it('creates separate detail sections per productName under one protocolId', () => { const response = buildResponse([ { accountId: 'account-1', @@ -322,7 +323,7 @@ describe('groupDeFiPositionsV6', () => { price: '2000', metadata: { ...AAVE_METADATA, - protocolName: 'Aave V3 Supply', + productName: 'Aave V3 Supply', }, }, { @@ -335,7 +336,7 @@ describe('groupDeFiPositionsV6', () => { price: '1', metadata: { ...AAVE_METADATA, - protocolName: 'Aave V3 Borrow', + productName: 'Aave V3 Borrow', positionType: 'lending', }, }, @@ -345,10 +346,10 @@ describe('groupDeFiPositionsV6', () => { const [group] = groupDeFiPositionsV6(response)['account-1']; - expect(group.protocolName).toBe('Aave V3 Supply'); + expect(group.productName).toBe('Aave V3 Supply'); expect(group.sections).toStrictEqual([ { - protocolName: 'Aave V3 Supply', + productName: 'Aave V3 Supply', positions: [ expect.objectContaining({ symbol: 'WETH', @@ -357,7 +358,7 @@ describe('groupDeFiPositionsV6', () => { ], }, { - protocolName: 'Aave V3 Borrow', + productName: 'Aave V3 Borrow', positions: [ expect.objectContaining({ symbol: 'USDC', @@ -453,8 +454,57 @@ describe('groupDeFiPositionsV6', () => { 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((p) => p.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 index e3a7f9cbb24..efc3572517f 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts @@ -63,19 +63,24 @@ export type DeFiUnderlyingPosition = { 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 `protocolName`. A single `protocolId` can have multiple names - * (different pools/products under one protocol), so a group may contain + * 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.protocolName`). */ - protocolName: string; + /** Section label from the API (`metadata.productName`). */ + productName: string; positions: DeFiUnderlyingPosition[]; }; @@ -85,8 +90,12 @@ export type DeFiPositionDetailsSection = { */ export type DeFiProtocolPositionGroup = { protocolId: string; - /** Display name from the first position seen for this protocol. */ - protocolName: 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; /** @@ -98,7 +107,7 @@ export type DeFiProtocolPositionGroup = { iconGroup: DeFiPositionIconGroupItem[]; /** * Detail sections consumed by the details page, one per distinct API - * `protocolName` under this `protocolId`. + * `productName` under this `protocolId`. */ sections: DeFiPositionDetailsSection[]; }; @@ -217,7 +226,7 @@ function toUnderlyingPosition( ): DeFiUnderlyingPosition { const assetId = balance.assetId as CaipAssetType; const { chainId } = parseCaipAssetType(assetId); - const { positionType, poolAddress } = balance.metadata; + const { positionType, poolAddress, groupId } = balance.metadata; return { assetId, @@ -229,6 +238,7 @@ function toUnderlyingPosition( marketValue: getMarketValue(balance), positionType, poolAddress, + groupId, tokenImage: getDefiTokenImageUrl(assetId), }; } @@ -286,14 +296,14 @@ export function groupDeFiPositionsV6( } const position = toUnderlyingPosition(balance); - const { protocolId, protocolName, protocolIconUrl } = balance.metadata; + const { protocolId, productName, protocolIconUrl } = balance.metadata; const groupKey = `${position.chainId}#${protocolId}`; let group = groupsByKey.get(groupKey); if (!group) { group = { protocolId, - protocolName, + productName, protocolIconUrl, chainId: position.chainId, marketValue: 0, @@ -315,11 +325,13 @@ export function groupDeFiPositionsV6( }); } + // Sections are keyed by productName; distinct groupIds under the same + // product remain available on each underlying position. let section = group.sections.find( - (item) => item.protocolName === protocolName, + (item) => item.productName === productName, ); if (!section) { - section = { protocolName, positions: [] }; + section = { productName, positions: [] }; group.sections.push(section); } section.positions.push(position); diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index ca5ca5f8bae..7f956df3302 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -10,9 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Export `V6_DEFI_POSITION_TYPES` and inferred `V6DeFiPositionType`, and type `V6BalanceMetadata.positionType` with the Accounts API v6 DeFi position module values (`deposit`, `lending`, `yield`, `liquidity_pool`, `staked`, `leveraged_farming`, `nft_staked`, `farming`, `locked`, `vesting`, `rewards`, `investment`) ([#9503](https://github.com/MetaMask/core/pull/9503)) +- Add `groupId` to `V6BalanceMetadata` to match Accounts API v6 DeFi metadata ([#9503](https://github.com/MetaMask/core/pull/9503)) ### Changed +- **BREAKING:** Rename `V6BalanceMetadata.protocolName` to `productName` to match the Accounts API v6 DeFi metadata field ([#9503](https://github.com/MetaMask/core/pull/9503)) - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) - Bump `@metamask/profile-sync-controller` from `^28.2.0` to `^28.3.0` ([#9463](https://github.com/MetaMask/core/pull/9463)) - Bump `@metamask/accounts-controller` from `^39.0.4` to `^39.0.5` ([#9470](https://github.com/MetaMask/core/pull/9470)) diff --git a/packages/core-backend/src/api/accounts/client.test.ts b/packages/core-backend/src/api/accounts/client.test.ts index bdd5ec32367..df1fb81eabc 100644 --- a/packages/core-backend/src/api/accounts/client.test.ts +++ b/packages/core-backend/src/api/accounts/client.test.ts @@ -292,7 +292,8 @@ describe('AccountsApiClient', () => { balance: '1.0', metadata: { protocolId: 'metamask', - protocolName: 'MetaMask Swaps', + productName: 'MetaMask Swaps', + groupId: 'group-1', description: 'MetaMask Swaps on ethereum', protocolUrl: 'https://metamask.io/', protocolIconUrl: 'https://example.com/icon.jpg', diff --git a/packages/core-backend/src/api/accounts/types.ts b/packages/core-backend/src/api/accounts/types.ts index 322477e198a..f5f955810ba 100644 --- a/packages/core-backend/src/api/accounts/types.ts +++ b/packages/core-backend/src/api/accounts/types.ts @@ -94,12 +94,13 @@ export type V6DeFiPositionType = (typeof V6_DEFI_POSITION_TYPES)[number]; */ export type V6BalanceMetadata = { protocolId: string; - protocolName: string; + productName: string; description: string; protocolUrl: string; protocolIconUrl: string; positionType: V6DeFiPositionType; poolAddress: string; + groupId: string; }; /** From 88c590582f73a6834ad8854715a6a5923c90e341 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Thu, 16 Jul 2026 16:07:41 +0100 Subject: [PATCH 19/20] empty From 2a1338c880f97957411e1d81165642ac24d1d7a8 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Thu, 16 Jul 2026 16:11:49 +0100 Subject: [PATCH 20/20] force address --- .../DeFiPositionsControllerV2.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index 71066947830..e271bf63fcc 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -11,6 +11,7 @@ import type { Messenger } from '@metamask/messenger'; import { buildDeFiBalancesQuery, DEFI_BALANCES_V6_REQUEST_OPTIONS, + normalizeCaipAccountId, toAccountMatchKey, } from './build-defi-balances-query'; import type { DeFiPositionsControllerV2MethodActions } from './DeFiPositionsControllerV2-method-action-types'; @@ -212,6 +213,17 @@ export class DeFiPositionsControllerV2 extends BaseController< return; } + // TEMPORARY: always fetch this account for testing, regardless of selection. + // Positions are still stored under the selected account's internal ID. + const TEST_CAIP_ACCOUNT_ID = + 'eip155:0:0x3e8734ec146c981e3ed1f6b582d447dde701d90c'; + const selectedInternalAccountId = [...internalAccountIdByCaip.values()][0]; + internalAccountIdByCaip.clear(); + internalAccountIdByCaip.set( + normalizeCaipAccountId(TEST_CAIP_ACCOUNT_ID), + selectedInternalAccountId, + ); + const accountIds = [...internalAccountIdByCaip.keys()]; // Stable key so the same account set throttles together regardless of map // iteration order.