diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index fc7a60d3c7..1f7d0ece40 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,6 +9,11 @@ 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 `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` - 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 new file mode 100644 index 0000000000..1077d1da23 --- /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 (no HTTP, no regroup, no state write). + * 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.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts new file mode 100644 index 0000000000..931cf6103a --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -0,0 +1,493 @@ +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', + }, + }, + }, +]; + +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: 'deposit', + 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 = (): boolean => true, + getVsCurrency = (): string => 'USD', + minimumFetchIntervalMs, + mockGroupAccounts = GROUP_ACCOUNTS, + mockFetchV6MultiAccountBalances = jest + .fn() + .mockResolvedValue(buildMockBalancesResponse()), + state, +}: { + isEnabled?: () => boolean; + getVsCurrency?: () => string; + minimumFetchIntervalMs?: number; + 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, + }); + + 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: 'deposit', + 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/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts new file mode 100644 index 0000000000..7106694783 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -0,0 +1,283 @@ +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, + toAccountMatchKey, +} from './build-defi-balances-query'; +import type { DeFiPositionsControllerV2MethodActions } from './DeFiPositionsControllerV2-method-action-types'; +import type { DeFiPositionsByAccount } from './group-defi-positions-v6'; +import { groupDeFiPositionsV6 } from './group-defi-positions-v6'; + +const controllerName = 'DeFiPositionsControllerV2'; + +const ONE_MINUTE_IN_MS = 60_000; + +const MESSENGER_EXPOSED_METHODS = ['fetchDeFiPositions'] as const; + +export type { + DeFiPositionsByAccount, + DeFiProtocolPositionGroup, + 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 = { + /** + * DeFi positions keyed by internal MetaMask account ID (`InternalAccount.id`, + * the same key AssetsController uses). Each account maps to a flat list of + * protocol groups shown in the DeFi tab, each carrying its own `chainId` for + * filtering plus the details-page sections embedded inside it. This is + * exactly the shape the client consumes, so no further transformation is + * needed on read. + * + * Named `allDeFiPositionsV2` (rather than `allDeFiPositions`) so it can live + * alongside the legacy `DeFiPositionsController` in clients that flatten every + * controller's state into a single object (e.g. the extension background), + * without colliding on the shared `allDeFiPositions` key. + */ + allDeFiPositionsV2: DeFiPositionsByAccount; +}; + +const controllerMetadata: StateMetadata = { + allDeFiPositionsV2: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, +}; + +export const getDefaultDeFiPositionsControllerV2State = + (): DeFiPositionsControllerV2State => { + return { + allDeFiPositionsV2: {}, + }; + }; + +export type DeFiPositionsControllerV2GetStateAction = ControllerGetStateAction< + typeof controllerName, + DeFiPositionsControllerV2State +>; + +export type DeFiPositionsControllerV2Actions = + | DeFiPositionsControllerV2GetStateAction + | DeFiPositionsControllerV2MethodActions; + +export type DeFiPositionsControllerV2StateChangedEvent = + ControllerStateChangedEvent< + typeof controllerName, + DeFiPositionsControllerV2State + >; + +export type DeFiPositionsControllerV2Events = + DeFiPositionsControllerV2StateChangedEvent; + +/** + * The external actions available to the {@link DeFiPositionsControllerV2}. + */ +export type AllowedActions = + AccountTreeControllerGetAccountsFromSelectedAccountGroupAction; + +/** + * The external events available to the {@link DeFiPositionsControllerV2}. + * + * None 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 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(); + + /** + * @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, + getVsCurrency, + 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 (no HTTP, no regroup, no state write). + * Disabled controllers and empty account groups return without fetching. + */ + async fetchDeFiPositions(): Promise { + if (!this.#isEnabled()) { + return; + } + + const selectedAccounts = this.messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ); + + const { networks, internalAccountIdByCaip } = + buildDeFiBalancesQuery(selectedAccounts); + + if (internalAccountIdByCaip.size === 0 || networks.length === 0) { + return; + } + + const accountIds = [...internalAccountIdByCaip.keys()]; + // 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); + if ( + lastFetchedAt !== undefined && + now - lastFetchedAt < this.#minimumFetchIntervalMs + ) { + return; + } + // 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 { + 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(), + }); + + // 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 => + internalAccountIdByMatchKey.get(toAccountMatchKey(responseAccountId)); + + const positionsByAccount = groupDeFiPositionsV6( + response, + resolveAccountId, + ); + + this.update((state) => { + for (const [accountId, positions] of Object.entries( + positionsByAccount, + )) { + state.allDeFiPositionsV2[accountId] = positions; + } + }); + } catch (error) { + // 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 what analytics will be needed before re-adding. + } +} 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 0000000000..4ff039434b --- /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/build-defi-balances-query.ts b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts new file mode 100644 index 0000000000..5d35c1a2a4 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts @@ -0,0 +1,174 @@ +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, + parseCaipAccountId, + toCaipAccountId, +} from '@metamask/utils'; + +/** + * Networks the DeFi balances (v6 multiaccount) endpoint supports. + * Cross-section of the supported chains from: + * https://developers.zerion.io/supported-blockchains + * https://accounts.api.cx.metamask.io/v2/supportedNetworks + */ +export const DEFI_SUPPORTED_NETWORKS: readonly CaipChainId[] = [ + 'eip155:1', + 'eip155:137', + 'eip155:56', + 'eip155:1329', + 'eip155:43114', + 'eip155:59144', + 'eip155:8453', + 'eip155:10', + 'eip155:42161', + 'eip155:143', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'eip155:999', + 'eip155:5042', +]; + +const SOLANA_MAINNET_CAIP_CHAIN_ID: CaipChainId = SolScope.Mainnet; + +/** + * 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, +} as const; + +export type DeFiBalancesQuery = { + /** 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; +}; + +/** + * 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 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. + * + * Picks the group's EVM account (queried across all supported EVM chains) and + * its Solana account (queried on supported Solana chains). Enabled-network + * filtering is intentionally omitted here: positions are stored per chain, so + * the client can filter by enabled networks when reading state. + * + * @param internalAccounts - Accounts belonging to the selected account group. + * @returns Networks and a CAIP→internal account ID map for the v6 multiaccount + * balances request. Map keys are the CAIP account IDs to query. + */ +export function buildDeFiBalancesQuery( + internalAccounts: InternalAccount[], +): DeFiBalancesQuery { + const evmNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) => + network.startsWith(`${KnownCaipNamespace.Eip155}:`), + ); + const solanaNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) => + network.startsWith(`${KnownCaipNamespace.Solana}:`), + ); + + const networks: CaipChainId[] = []; + const internalAccountIdByCaip = new Map(); + + const evmAccount = internalAccounts.find((account) => + isEvmAccountType(account.type), + ); + if (evmAccount && evmNetworks.length > 0) { + internalAccountIdByCaip.set( + normalizeCaipAccountId(toEvmCaipAccountId(evmAccount.address)), + evmAccount.id, + ); + networks.push(...evmNetworks); + } + + const solanaAccount = internalAccounts.find( + (account) => account.type === SolAccountType.DataAccount, + ); + if (solanaAccount && solanaNetworks.length > 0) { + const [, solanaReference] = SOLANA_MAINNET_CAIP_CHAIN_ID.split(':'); + internalAccountIdByCaip.set( + normalizeCaipAccountId( + toCaipAccountId( + KnownCaipNamespace.Solana, + solanaReference, + solanaAccount.address, + ), + ), + solanaAccount.id, + ); + networks.push(...solanaNetworks); + } + + return { + networks: [...new Set(networks)] as CaipChainId[], + internalAccountIdByCaip, + }; +} diff --git a/packages/assets-controllers/src/DeFiPositionsController/defi-visualizer.html b/packages/assets-controllers/src/DeFiPositionsController/defi-visualizer.html new file mode 100644 index 0000000000..20eca47f1c --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/defi-visualizer.html @@ -0,0 +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.test.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.test.ts new file mode 100644 index 0000000000..18581a8854 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.test.ts @@ -0,0 +1,460 @@ +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: 'deposit', + 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: 'deposit', + poolAddress: '0xpool2', + }, + }, + { + category: 'defi', + assetId: BASE_WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '2', + price: '2000', + metadata: AAVE_METADATA, + }, + ], + }, + ]); + + const result = groupDeFiPositionsV6(response); + + expect(Object.keys(result)).toStrictEqual(['eip155:0:0xabc']); + expect(result['eip155:0:0xabc']).toHaveLength(2); + + const ethGroup = result['eip155:0:0xabc'].find( + (group) => group.chainId === 'eip155:1', + ); + const baseGroup = result['eip155:0:0xabc'].find( + (group) => group.chainId === 'eip155:8453', + ); + + expect(ethGroup).toMatchObject({ + protocolId: 'aave-v3', + 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('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([ + { + 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: 'lending', + }, + }, + ], + }, + ]); + + 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: 'deposit', + }), + ], + }, + { + protocolName: 'Aave V3 Borrow', + positions: [ + expect.objectContaining({ + symbol: 'USDC', + positionType: 'lending', + }), + ], + }, + ]); + }); + + it('dedupes icon-group symbols and moves ETH/WETH to the front', () => { + const response = buildResponse([ + { + accountId: 'account-1', + balances: [ + { + category: 'defi', + assetId: USDC_ASSET_ID, + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + balance: '100', + price: '1', + metadata: AAVE_METADATA, + }, + { + category: 'defi', + assetId: WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '1', + price: '2000', + metadata: AAVE_METADATA, + }, + { + category: 'defi', + assetId: WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '0.5', + price: '2000', + metadata: { + ...AAVE_METADATA, + positionType: 'rewards', + }, + }, + ], + }, + ]); + + const [group] = groupDeFiPositionsV6(response)['account-1']; + + expect(group.iconGroup.map((item) => item.symbol)).toStrictEqual([ + 'WETH', + 'USDC', + ]); + expect(group.iconGroup[0].avatarValue).toBe( + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2.png', + ); + }); + + it('builds underlying positions with token images and chain IDs', () => { + const response = buildResponse([ + { + accountId: 'account-1', + balances: [ + { + category: 'defi', + assetId: WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '1.5', + price: '2000', + metadata: AAVE_METADATA, + }, + ], + }, + ]); + + const [group] = groupDeFiPositionsV6(response)['account-1']; + const [position] = group.sections[0].positions; + + expect(position).toStrictEqual({ + assetId: WETH_ASSET_ID, + chainId: 'eip155:1', + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + balance: '1.5', + marketValue: 3000, + positionType: 'deposit', + poolAddress: '0xpool', + 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 new file mode 100644 index 0000000000..e3a7f9cbb2 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts @@ -0,0 +1,339 @@ +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 { + 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'; + +/** + * Possible `positionType` values from Accounts API v6 DeFi metadata. + * Re-export of {@link V6_DEFI_POSITION_TYPES} from `@metamask/core-backend`. + */ +export const DEFI_POSITION_TYPES = V6_DEFI_POSITION_TYPES; + +/** + * The specific module or functionality within a DeFi protocol where a position + * is held. Alias of {@link V6DeFiPositionType} from `@metamask/core-backend`. + */ +export type DeFiPositionType = V6DeFiPositionType; + +/** + * Position types whose fiat value is a liability and is subtracted from the + * protocol group's aggregated `marketValue`. + */ +export const DEFI_POSITION_LIABILITY_TYPES: ReadonlySet = + new Set(['lending']); + +/** + * An icon-group entry shown next to a protocol in the DeFi tab list. + */ +export type DeFiPositionIconGroupItem = { + /** Token icon URL, when one can be built for the asset. */ + avatarValue?: string; + symbol: string; +}; + +/** + * A single underlying position row shown on the DeFi details page. + */ +export type DeFiUnderlyingPosition = { + assetId: CaipAssetType; + chainId: CaipChainId; + symbol: string; + name: string; + decimals: number; + /** Raw balance string as returned by the API. */ + balance: string; + /** Fiat market value in the requested currency, when a price is available. */ + marketValue?: number; + /** Position type from protocol metadata. */ + positionType: DeFiPositionType; + /** Address of the pool this position belongs to. */ + poolAddress: string; + /** 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 + * several sections. + */ +export type DeFiPositionDetailsSection = { + /** Section label from the API (`metadata.protocolName`). */ + protocolName: string; + positions: DeFiUnderlyingPosition[]; +}; + +/** + * One row in the DeFi tab list (a protocol on a given chain), with the details + * needed to render the details page embedded directly inside it. + */ +export type DeFiProtocolPositionGroup = { + protocolId: string; + /** Display name from the first position seen for this protocol. */ + protocolName: string; + protocolIconUrl: string; + chainId: CaipChainId; + /** + * Aggregated fiat market value across all positions in the group. + * `lending` positions are subtracted; all other types are added. + */ + marketValue: number; + /** Icon-group entries for the list row. */ + iconGroup: DeFiPositionIconGroupItem[]; + /** + * Detail sections consumed by the details page, one per distinct API + * `protocolName` under this `protocolId`. + */ + sections: DeFiPositionDetailsSection[]; +}; + +/** + * DeFi positions for every queried account, keyed by the internal MetaMask + * account ID (`InternalAccount.id` UUID), the same key AssetsController uses. + * Each account maps to a flat list of protocol groups; filter by each group's + * `chainId` rather than digging through a nested chain map. + */ +export type DeFiPositionsByAccount = { + [accountId: string]: DeFiProtocolPositionGroup[]; +}; + +// Prefer ETH/WETH first in the list-row icon stack when a protocol has multiple +// underlyings. Display-only. +const SYMBOL_PRIORITY = ['ETH', 'WETH']; + +type DefiBalanceWithMetadata = V6BalanceItem & { metadata: V6BalanceMetadata }; + +/** + * Builds a static token icon URL for a CAIP asset ID. + * + * @param assetId - The CAIP-19 asset ID. + * @returns The token icon URL, or `undefined` when it cannot be built. + */ +function getDefiTokenImageUrl(assetId: CaipAssetType): string | undefined { + try { + const { chainId } = parseCaipAssetType(assetId); + const { namespace } = parseCaipChainId(chainId); + const isEvm = namespace === KnownCaipNamespace.Eip155; + const normalizedAssetId = (isEvm ? assetId.toLowerCase() : assetId).replace( + /:/gu, + '/', + ); + + return `${STATIC_METAMASK_BASE_URL}/api/v2/tokenIcons/assets/${normalizedAssetId}.png`; + } catch { + return undefined; + } +} + +/** + * Returns whether a balance row is a DeFi position carrying protocol metadata. + * + * @param balance - A balance row from the v6 API. + * @returns True when the row is a `category: defi` row with protocol metadata. + */ +function isDefiBalanceWithMetadata( + balance: V6BalanceItem, +): balance is DefiBalanceWithMetadata { + return ( + balance.category === 'defi' && + balance.metadata !== undefined && + (balance.metadata as Partial).protocolId !== undefined + ); +} + +/** + * Returns the fiat market value for a v6 DeFi balance row. + * + * @param balance - A balance row from the v6 API. + * @returns The fiat value, or `undefined` when price is missing or the + * balance/price is invalid. + */ +function getMarketValue(balance: V6BalanceItem): number | undefined { + if (balance.price === undefined) { + return undefined; + } + + const normalizedBalance = Number.parseFloat(balance.balance); + const price = Number.parseFloat(balance.price); + + if (!Number.isFinite(normalizedBalance) || !Number.isFinite(price)) { + return undefined; + } + + return normalizedBalance * price; +} + +/** + * Returns the sign used when rolling a position's market value into a protocol + * group total. Liability types (currently `lending`) subtract; all others add. + * + * @param positionType - The position's protocol module type. + * @returns `-1` for liabilities, `1` otherwise. + */ +function getMarketValueSign(positionType: DeFiPositionType): 1 | -1 { + return DEFI_POSITION_LIABILITY_TYPES.has(positionType) ? -1 : 1; +} + +/** + * Moves a priority symbol (ETH/WETH) to the front of the icon group, in place. + * + * @param iconGroup - The icon-group entries to reorder. + */ +function orderIconGroup(iconGroup: DeFiPositionIconGroupItem[]): void { + const priorityIndex = iconGroup.findIndex((item) => + SYMBOL_PRIORITY.includes(item.symbol), + ); + + if (priorityIndex > 0) { + const [priorityIcon] = iconGroup.splice(priorityIndex, 1); + iconGroup.unshift(priorityIcon); + } +} + +/** + * Maps a DeFi balance row to a details-page underlying position. + * + * @param balance - A DeFi balance row with protocol metadata. + * @returns The underlying position for the details page. + */ +function toUnderlyingPosition( + balance: DefiBalanceWithMetadata, +): DeFiUnderlyingPosition { + const assetId = balance.assetId as CaipAssetType; + const { chainId } = parseCaipAssetType(assetId); + const { positionType, poolAddress } = balance.metadata; + + return { + assetId, + chainId, + symbol: balance.symbol, + name: balance.name, + balance: balance.balance, + decimals: balance.decimals, + marketValue: getMarketValue(balance), + positionType, + poolAddress, + tokenImage: getDefiTokenImageUrl(assetId), + }; +} + +/** + * 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. 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, 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 | undefined = (id) => + id, +): 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); + if (accountId === undefined) { + continue; + } + + // Seed every queried account so accounts that no longer hold positions + // overwrite (clear) any previously stored data. + let groupsByKey = groupsByAccountKey.get(accountId); + if (!groupsByKey) { + groupsByKey = new Map(); + groupsByAccountKey.set(accountId, groupsByKey); + } + + for (const balance of account.balances) { + if (!isDefiBalanceWithMetadata(balance)) { + continue; + } + + const position = toUnderlyingPosition(balance); + const { protocolId, protocolName, protocolIconUrl } = balance.metadata; + const groupKey = `${position.chainId}#${protocolId}`; + + let group = groupsByKey.get(groupKey); + if (!group) { + group = { + protocolId, + protocolName, + protocolIconUrl, + chainId: position.chainId, + marketValue: 0, + iconGroup: [], + sections: [], + }; + groupsByKey.set(groupKey, group); + } + + if (position.marketValue !== undefined) { + group.marketValue += + position.marketValue * getMarketValueSign(position.positionType); + } + + if (!group.iconGroup.some((item) => item.symbol === position.symbol)) { + group.iconGroup.push({ + symbol: position.symbol, + avatarValue: position.tokenImage, + }); + } + + let section = group.sections.find( + (item) => item.protocolName === protocolName, + ); + if (!section) { + section = { protocolName, positions: [] }; + group.sections.push(section); + } + section.positions.push(position); + } + } + + const result: DeFiPositionsByAccount = {}; + for (const [accountId, groupsByKey] of groupsByAccountKey) { + const groups = [...groupsByKey.values()]; + for (const group of groups) { + orderIconGroup(group.iconGroup); + } + result[accountId] = groups; + } + + return result; +} diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 70cadfb015..c0f9e86f6a 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -264,6 +264,31 @@ export type { DeFiPositionsControllerMessenger, } from './DeFiPositionsController/DeFiPositionsController'; export type { GroupedDeFiPositions } from './DeFiPositionsController/group-defi-positions'; +export { + DeFiPositionsControllerV2, + getDefaultDeFiPositionsControllerV2State, + DEFI_POSITION_TYPES, + DEFI_POSITION_LIABILITY_TYPES, +} from './DeFiPositionsController/DeFiPositionsControllerV2'; +export type { + DeFiPositionsControllerV2State, + DeFiPositionsControllerV2Actions, + DeFiPositionsControllerV2Events, + DeFiPositionsControllerV2GetStateAction, + DeFiPositionsControllerV2StateChangedEvent, + DeFiPositionsControllerV2Messenger, + DeFiPositionsByAccount, + DeFiProtocolPositionGroup, + DeFiPositionDetailsSection, + DeFiUnderlyingPosition, + DeFiPositionIconGroupItem, + DeFiPositionType, +} 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, diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index c0e87c8bad..ca5ca5f8ba 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`) ([#9503](https://github.com/MetaMask/core/pull/9503)) + ### 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 3f08819c3f..1242b951f2 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 41eeb03176..322477e198 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 02c01b9838..9f30f02397 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 f88d719373..b58ac3abc5 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,