diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 8720be05f53e..90bfe1c8c038 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -148,8 +148,6 @@ import { TokenI } from '../../UI/Tokens/types'; import NetworkConnectionBanner from '../../UI/NetworkConnectionBanner'; import { selectAssetsDefiPositionsEnabled } from '../../../selectors/featureFlagController/assetsDefiPositions'; -import { selectHDKeyrings } from '../../../selectors/keyringController'; -import { UserProfileProperty } from '../../../util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; import { SwapBridgeNavigationLocation, useSwapBridgeNavigation, @@ -624,7 +622,7 @@ const Wallet = ({ ); const { toastRef } = useContext(ToastContext); - const { trackEvent, createEventBuilder, addTraitsToUser } = useAnalytics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const styles = useMemo(() => createStyles(theme), [theme]); const { colors } = theme; const dispatch = useDispatch(); @@ -770,8 +768,6 @@ const Wallet = ({ const currentToast = toastRef?.current; - const hdKeyrings = useSelector(selectHDKeyrings); - const accountName = useAccountName(); const accountGroupName = useAccountGroupName(); @@ -866,12 +862,6 @@ const Wallet = ({ checkAndNavigateToPredictGTM, ]); - useEffect(() => { - addTraitsToUser({ - [UserProfileProperty.NUMBER_OF_HD_ENTROPIES]: hdKeyrings.length, - }); - }, [addTraitsToUser, hdKeyrings.length]); - const isConnectionRemoved = useSelector(selectIsConnectionRemoved); useEffect(() => { diff --git a/app/core/Engine/controllers/analytics-controller/analytics-controller-init.test.ts b/app/core/Engine/controllers/analytics-controller/analytics-controller-init.test.ts new file mode 100644 index 000000000000..df514bb73b54 --- /dev/null +++ b/app/core/Engine/controllers/analytics-controller/analytics-controller-init.test.ts @@ -0,0 +1,405 @@ +import { analyticsControllerInit } from './analytics-controller-init'; +import { + AnalyticsController, + AnalyticsControllerMessenger, +} from '@metamask/analytics-controller'; +import { MessengerClientInitRequest } from '../../types'; +import { + AnalyticsControllerInitMessenger, + getAnalyticsControllerMessenger, +} from '../../messengers/analytics-controller-messenger'; +import { ExtendedMessenger } from '../../../ExtendedMessenger'; +import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; +import { buildMessengerClientInitRequestMock } from '../../utils/test-utils'; +import { analytics } from '../../../../util/analytics/analytics'; +import { getAccountCompositionTraits } from '../../../../util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData'; +import Logger from '../../../../util/Logger'; +import type { AccountsControllerState } from '@metamask/accounts-controller'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { KeyringAccountEntropyTypeOption } from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { createMockInternalAccount } from '../../../../util/test/accountsControllerTestUtils'; + +type InternalAccounts = AccountsControllerState['internalAccounts']['accounts']; + +jest.mock('@metamask/analytics-controller', () => ({ + ...jest.requireActual('@metamask/analytics-controller'), + AnalyticsController: jest.fn().mockImplementation(() => ({ + init: jest.fn(), + })), +})); + +jest.mock('./platform-adapter', () => ({ + createPlatformAdapter: jest.fn().mockReturnValue({}), +})); + +jest.mock('./platform-adapter-e2e', () => ({ + createPlatformAdapter: jest.fn().mockReturnValue({}), +})); + +jest.mock('../../../../util/test/utils', () => ({ + isE2E: false, +})); + +jest.mock('../../../Braze', () => ({ + getBrazePlugin: jest.fn().mockReturnValue({}), +})); + +jest.mock('../../../../util/analytics/analytics', () => ({ + analytics: { + identify: jest.fn(), + }, +})); + +jest.mock( + '../../../../util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData', + () => ({ + getAccountCompositionTraits: jest.fn().mockReturnValue({ trait: 'value' }), + }), +); + +jest.mock('../../../../util/Logger', () => ({ + __esModule: true, + default: { + error: jest.fn(), + }, +})); + +const mockAnalyticsIdentify = jest.mocked(analytics.identify); +const mockGetAccountCompositionTraits = jest.mocked( + getAccountCompositionTraits, +); +const mockLoggerError = jest.mocked(Logger.error); + +function buildInitMessengerMock(): jest.Mocked { + return { + subscribe: jest.fn(), + call: jest.fn(), + } as unknown as jest.Mocked; +} + +function getInitRequestMock( + overrides: Partial<{ + analyticsId: string; + persistedState: Record; + initMessenger: jest.Mocked; + }> = {}, +): jest.Mocked< + MessengerClientInitRequest< + AnalyticsControllerMessenger, + AnalyticsControllerInitMessenger + > +> { + const baseMessenger = new ExtendedMessenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + return { + ...buildMessengerClientInitRequestMock(baseMessenger), + controllerMessenger: getAnalyticsControllerMessenger( + baseMessenger as never, + ), + initMessenger: overrides.initMessenger ?? buildInitMessengerMock(), + analyticsId: overrides.analyticsId ?? 'test-analytics-id', + persistedState: overrides.persistedState ?? {}, + }; +} + +function getAccountsSubscribeCallback( + initMessengerMock: jest.Mocked, +): (accounts: InternalAccounts) => void { + const subscribeCall = initMessengerMock.subscribe.mock.calls.find( + ([event]) => event === 'AccountsController:stateChange', + ); + if (!subscribeCall) throw new Error('AccountsController subscribe not found'); + return subscribeCall[1] as (accounts: InternalAccounts) => void; +} + +function buildMockAccounts( + overrides: Partial = {}, +): InternalAccounts { + return { + 'account-1': { + ...createMockInternalAccount('0x1234', 'Account 1', KeyringTypes.hd), + id: 'account-1', + options: { + entropy: { + type: KeyringAccountEntropyTypeOption.Mnemonic, + id: 'entropy-1', + derivationPath: "m/44'/60'/0'/0/0", + groupIndex: 0, + }, + } as InternalAccount['options'], + }, + ...overrides, + }; +} + +describe('analyticsControllerInit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('controller initialization', () => { + it('returns the initialized controller', () => { + const { controller } = analyticsControllerInit(getInitRequestMock()); + expect(controller).toBeDefined(); + }); + + it('creates AnalyticsController with correct arguments', () => { + analyticsControllerInit(getInitRequestMock()); + + expect(AnalyticsController).toHaveBeenCalledWith({ + messenger: expect.any(Object), + state: expect.objectContaining({ analyticsId: 'test-analytics-id' }), + platformAdapter: expect.any(Object), + isAnonymousEventsFeatureEnabled: true, + }); + }); + + it('uses persisted optedIn state when available', () => { + analyticsControllerInit( + getInitRequestMock({ + persistedState: { AnalyticsController: { optedIn: true } }, + }), + ); + + expect(AnalyticsController).toHaveBeenCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ optedIn: true }), + }), + ); + }); + + it('calls controller.init()', () => { + analyticsControllerInit(getInitRequestMock()); + + const controllerMock = jest.mocked(AnalyticsController); + expect(controllerMock.mock.results[0].value.init).toHaveBeenCalled(); + }); + }); + + describe('platform adapter', () => { + it('uses standard platform adapter when not in E2E', () => { + const { createPlatformAdapter } = jest.requireMock('./platform-adapter'); + analyticsControllerInit(getInitRequestMock()); + expect(createPlatformAdapter).toHaveBeenCalled(); + }); + + it('uses E2E platform adapter when isE2E is true', () => { + jest.resetModules(); + jest.doMock('../../../../util/test/utils', () => ({ isE2E: true })); + const { createPlatformAdapter: createE2E } = jest.requireMock( + './platform-adapter-e2e', + ); + // Re-require to pick up the new isE2E mock + const { analyticsControllerInit: initFn } = jest.requireMock( + './analytics-controller-init', + ); + initFn?.(getInitRequestMock()); + // The E2E mock was registered; verify it was set up + expect(createE2E).toBeDefined(); + }); + }); + + describe('AccountsController:stateChange subscription', () => { + it('subscribes to AccountsController:stateChange', () => { + const initMessenger = buildInitMessengerMock(); + analyticsControllerInit(getInitRequestMock({ initMessenger })); + + expect(initMessenger.subscribe).toHaveBeenCalledWith( + 'AccountsController:stateChange', + expect.any(Function), + expect.any(Function), + ); + }); + + it('uses internalAccounts.accounts as the selector', () => { + const initMessenger = buildInitMessengerMock(); + analyticsControllerInit(getInitRequestMock({ initMessenger })); + + const [, , selector] = initMessenger.subscribe.mock.calls.find( + ([event]) => event === 'AccountsController:stateChange', + ) as Parameters; + + const mockState = { + internalAccounts: { accounts: buildMockAccounts() }, + }; + const result = ( + selector as unknown as (state: typeof mockState) => unknown + )(mockState); + expect(result).toBe(mockState.internalAccounts.accounts); + }); + + it('calls analytics.identify when account composition changes', () => { + const initMessenger = buildInitMessengerMock(); + analyticsControllerInit(getInitRequestMock({ initMessenger })); + + const callback = getAccountsSubscribeCallback(initMessenger); + const accounts = buildMockAccounts(); + callback(accounts); + + expect(mockAnalyticsIdentify).toHaveBeenCalledWith({ trait: 'value' }); + expect(mockGetAccountCompositionTraits).toHaveBeenCalledWith(accounts); + }); + + it('does not call analytics.identify when account composition has not changed', () => { + const initMessenger = buildInitMessengerMock(); + analyticsControllerInit(getInitRequestMock({ initMessenger })); + + const callback = getAccountsSubscribeCallback(initMessenger); + const accounts = buildMockAccounts(); + + callback(accounts); + jest.clearAllMocks(); + callback(accounts); + + expect(mockAnalyticsIdentify).not.toHaveBeenCalled(); + }); + + it('calls analytics.identify again when account composition changes after initial call', () => { + const initMessenger = buildInitMessengerMock(); + analyticsControllerInit(getInitRequestMock({ initMessenger })); + + const callback = getAccountsSubscribeCallback(initMessenger); + + const accounts1 = buildMockAccounts(); + callback(accounts1); + jest.clearAllMocks(); + + const accounts2 = buildMockAccounts({ + 'account-2': { + ...createMockInternalAccount( + '0x5678', + 'Account 2', + KeyringTypes.simple, + ), + id: 'account-2', + }, + }); + callback(accounts2); + + expect(mockAnalyticsIdentify).toHaveBeenCalled(); + }); + + it('logs an error when analytics.identify throws', () => { + const initMessenger = buildInitMessengerMock(); + analyticsControllerInit(getInitRequestMock({ initMessenger })); + + const mockError = new Error('identify failed'); + mockAnalyticsIdentify.mockImplementationOnce(() => { + throw mockError; + }); + + const callback = getAccountsSubscribeCallback(initMessenger); + callback(buildMockAccounts()); + + expect(mockLoggerError).toHaveBeenCalledWith( + mockError, + 'analyticsControllerInit: Error updating account composition traits', + ); + }); + }); + + describe('getCompositionFingerprint', () => { + it('produces stable fingerprint including keyring type and entropy', () => { + const initMessenger = buildInitMessengerMock(); + analyticsControllerInit(getInitRequestMock({ initMessenger })); + const callback = getAccountsSubscribeCallback(initMessenger); + + const accounts = buildMockAccounts(); + callback(accounts); + jest.clearAllMocks(); + // Same accounts, same fingerprint — should not re-identify + callback(accounts); + expect(mockAnalyticsIdentify).not.toHaveBeenCalled(); + }); + + it('produces different fingerprint when keyring type changes', () => { + const initMessenger = buildInitMessengerMock(); + analyticsControllerInit(getInitRequestMock({ initMessenger })); + const callback = getAccountsSubscribeCallback(initMessenger); + + callback(buildMockAccounts()); + jest.clearAllMocks(); + + const accountsChanged: InternalAccounts = { + 'account-1': { + ...createMockInternalAccount( + '0x1234', + 'Account 1', + KeyringTypes.simple, + ), + id: 'account-1', + }, + }; + + callback(accountsChanged); + expect(mockAnalyticsIdentify).toHaveBeenCalled(); + }); + + it('handles accounts with missing entropy gracefully', () => { + const initMessenger = buildInitMessengerMock(); + analyticsControllerInit(getInitRequestMock({ initMessenger })); + const callback = getAccountsSubscribeCallback(initMessenger); + + const accountsNoEntropy: InternalAccounts = { + 'account-1': { + ...createMockInternalAccount('0x1234', 'Account 1', KeyringTypes.hd), + id: 'account-1', + }, + }; + + expect(() => callback(accountsNoEntropy)).not.toThrow(); + expect(mockAnalyticsIdentify).toHaveBeenCalled(); + }); + + it('handles accounts with missing keyring metadata gracefully', () => { + const initMessenger = buildInitMessengerMock(); + analyticsControllerInit(getInitRequestMock({ initMessenger })); + const callback = getAccountsSubscribeCallback(initMessenger); + + const accountsNoKeyring: InternalAccounts = { + 'account-1': { + ...createMockInternalAccount('0x1234', 'Account 1', KeyringTypes.hd), + id: 'account-1', + metadata: { + importTime: 0, + name: 'Account 1', + } as InternalAccount['metadata'], + }, + }; + + expect(() => callback(accountsNoKeyring)).not.toThrow(); + }); + + it('produces sorted, stable output for multiple accounts', () => { + const initMessenger = buildInitMessengerMock(); + analyticsControllerInit(getInitRequestMock({ initMessenger })); + const callback = getAccountsSubscribeCallback(initMessenger); + + const accountsAB: InternalAccounts = { + 'account-a': { + ...createMockInternalAccount('0xaaa', 'A', KeyringTypes.hd), + id: 'account-a', + }, + 'account-b': { + ...createMockInternalAccount('0xbbb', 'B', KeyringTypes.simple), + id: 'account-b', + }, + }; + + callback(accountsAB); + jest.clearAllMocks(); + + // Same accounts, different insertion order — fingerprint should be identical (sorted) + const accountsBA: InternalAccounts = { + 'account-b': accountsAB['account-b'], + 'account-a': accountsAB['account-a'], + }; + + callback(accountsBA); + expect(mockAnalyticsIdentify).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/core/Engine/controllers/analytics-controller/analytics-controller-init.ts b/app/core/Engine/controllers/analytics-controller/analytics-controller-init.ts index 264ee4beb634..0022e98d2057 100644 --- a/app/core/Engine/controllers/analytics-controller/analytics-controller-init.ts +++ b/app/core/Engine/controllers/analytics-controller/analytics-controller-init.ts @@ -9,6 +9,39 @@ import { createPlatformAdapter } from './platform-adapter'; import { createPlatformAdapter as createE2EPlatformAdapter } from './platform-adapter-e2e'; import { isE2E } from '../../../../util/test/utils'; import { getBrazePlugin } from '../../../Braze'; +import type { AnalyticsControllerInitMessenger } from '../../messengers/analytics-controller-messenger'; +import type { AccountsControllerState } from '@metamask/accounts-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { KeyringAccountEntropyTypeOption } from '@metamask/keyring-api'; +import { analytics } from '../../../../util/analytics/analytics'; +import { getAccountCompositionTraits } from '../../../../util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData'; +import Logger from '../../../../util/Logger'; + +/** + * Produces a stable string fingerprint from only the fields that affect wallet + * composition metrics. Fields like `lastSelected` and account names are + * intentionally excluded so that account switches and renames do not trigger + * an unnecessary identify call. + */ +type InternalAccounts = AccountsControllerState['internalAccounts']['accounts']; + +function getCompositionFingerprint(accounts: InternalAccounts): string { + return Object.entries(accounts) + .map(([id, acct]) => { + const keyringType = acct.metadata?.keyring?.type ?? ''; + const entropy: InternalAccount['options']['entropy'] = + acct.options?.entropy; + const isMnemonic = + entropy?.type === KeyringAccountEntropyTypeOption.Mnemonic && + !!entropy.id && + entropy.groupIndex !== undefined; + const entropyId = isMnemonic ? entropy.id : ''; + const entropyGroupIndex = isMnemonic ? entropy.groupIndex : ''; + return `${id}|${keyringType}|${entropy?.type ?? ''}|${entropyId}|${entropyGroupIndex}`; + }) + .sort((a, b) => a.localeCompare(b)) + .join(';'); +} /** * Initialize the analytics controller. @@ -17,12 +50,14 @@ import { getBrazePlugin } from '../../../Braze'; * @param request.controllerMessenger - The messenger to use for the controller. * @param request.analyticsId - The analytics ID to use. * @param request.persistedState - The persisted state for all controllers. + * @param request.initMessenger - The init messenger for accounts state subscriptions. * @returns The initialized controller. */ export const analyticsControllerInit: MessengerClientInitFunction< AnalyticsController, - AnalyticsControllerMessenger -> = ({ controllerMessenger, analyticsId, persistedState }) => { + AnalyticsControllerMessenger, + AnalyticsControllerInitMessenger +> = ({ controllerMessenger, analyticsId, persistedState, initMessenger }) => { const persistedAnalyticsState = persistedState.AnalyticsController; const defaultState = getDefaultAnalyticsControllerState(); @@ -44,6 +79,25 @@ export const analyticsControllerInit: MessengerClientInitFunction< controller.init(); + let lastCompositionFingerprint = ''; + initMessenger.subscribe( + 'AccountsController:stateChange', + (accounts: InternalAccounts) => { + const fingerprint = getCompositionFingerprint(accounts); + if (fingerprint === lastCompositionFingerprint) return; + try { + analytics.identify(getAccountCompositionTraits(accounts)); + lastCompositionFingerprint = fingerprint; + } catch (error) { + Logger.error( + error as Error, + 'analyticsControllerInit: Error updating account composition traits', + ); + } + }, + (accountsState) => accountsState.internalAccounts.accounts, + ); + return { controller, }; diff --git a/app/core/Engine/messengers/analytics-controller-messenger.ts b/app/core/Engine/messengers/analytics-controller-messenger.ts index 0410213ca099..0e2400103f77 100644 --- a/app/core/Engine/messengers/analytics-controller-messenger.ts +++ b/app/core/Engine/messengers/analytics-controller-messenger.ts @@ -4,6 +4,7 @@ import { MessengerEvents, MessengerActions, } from '@metamask/messenger'; +import type { AccountsControllerChangeEvent } from '@metamask/accounts-controller'; import { RootMessenger } from '../types'; /** @@ -26,3 +27,37 @@ export function getAnalyticsControllerMessenger( }); return messenger; } + +export type AnalyticsControllerInitMessenger = ReturnType< + typeof getAnalyticsControllerInitMessenger +>; + +/** + * Get the init messenger for the AnalyticsController. + * Scoped to analytics-init dependencies like accounts state changes for + * account composition trait updates. + * + * @param rootMessenger - The root messenger. + * @returns The AnalyticsControllerInitMessenger. + */ +export function getAnalyticsControllerInitMessenger( + rootMessenger: RootMessenger, +) { + const messenger = new Messenger< + 'AnalyticsControllerInit', + never, + AccountsControllerChangeEvent, + RootMessenger + >({ + namespace: 'AnalyticsControllerInit', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: [], + events: ['AccountsController:stateChange'], + messenger, + }); + + return messenger; +} diff --git a/app/core/Engine/messengers/index.ts b/app/core/Engine/messengers/index.ts index 6aab16be9aa1..1a3bc7ee7556 100644 --- a/app/core/Engine/messengers/index.ts +++ b/app/core/Engine/messengers/index.ts @@ -146,7 +146,10 @@ import { getProfileMetricsControllerInitMessenger, } from './profile-metrics-controller-messenger'; import { getProfileMetricsServiceMessenger } from './profile-metrics-service-messenger'; -import { getAnalyticsControllerMessenger } from './analytics-controller-messenger'; +import { + getAnalyticsControllerInitMessenger, + getAnalyticsControllerMessenger, +} from './analytics-controller-messenger'; import { getAiDigestControllerMessenger } from './ai-digest-controller-messenger'; import { getSocialServiceMessenger } from './social-service-messenger'; import { getSocialControllerMessenger } from './social-controller-messenger'; @@ -464,7 +467,7 @@ export const MESSENGER_FACTORIES = { }, AnalyticsController: { getMessenger: getAnalyticsControllerMessenger, - getInitMessenger: noop, + getInitMessenger: getAnalyticsControllerInitMessenger, }, AiDigestController: { getMessenger: getAiDigestControllerMessenger, diff --git a/app/util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types.ts b/app/util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types.ts index aab325c5da20..638c4b0e0ca7 100644 --- a/app/util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types.ts +++ b/app/util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types.ts @@ -14,6 +14,11 @@ export enum UserProfileProperty { CURRENT_CURRENCY = 'current_currency', HAS_MARKETING_CONSENT = 'has_marketing_consent', NUMBER_OF_HD_ENTROPIES = 'number_of_hd_entropies', + NUMBER_OF_ACCOUNT_GROUPS = 'number_of_account_groups', + NUMBER_OF_IMPORTED_ACCOUNTS = 'number_of_imported_accounts', + NUMBER_OF_LEDGER_ACCOUNTS = 'number_of_ledger_accounts', + NUMBER_OF_QR_HARDWARE_ACCOUNTS = 'number_of_qr_hardware_accounts', + NUMBER_OF_HARDWARE_WALLETS = 'number_of_hardware_wallets', CHAIN_IDS = 'chain_id_list', HAS_REWARDS_OPTED_IN = 'has_rewards_opted_in', REWARDS_REFERRED = 'rewards_referred', @@ -33,7 +38,12 @@ export interface UserProfileMetaData { [UserProfileProperty.PRIMARY_CURRENCY]?: string; [UserProfileProperty.CURRENT_CURRENCY]?: string; [UserProfileProperty.HAS_MARKETING_CONSENT]: boolean; - [UserProfileProperty.NUMBER_OF_HD_ENTROPIES]?: number; + [UserProfileProperty.NUMBER_OF_HD_ENTROPIES]: number; + [UserProfileProperty.NUMBER_OF_ACCOUNT_GROUPS]: number; + [UserProfileProperty.NUMBER_OF_IMPORTED_ACCOUNTS]: number; + [UserProfileProperty.NUMBER_OF_LEDGER_ACCOUNTS]: number; + [UserProfileProperty.NUMBER_OF_QR_HARDWARE_ACCOUNTS]: number; + [UserProfileProperty.NUMBER_OF_HARDWARE_WALLETS]: number; [UserProfileProperty.CHAIN_IDS]: CaipChainId[]; [UserProfileProperty.HAS_REWARDS_OPTED_IN]?: string; [UserProfileProperty.REWARDS_REFERRED]?: boolean; diff --git a/app/util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData.test.ts b/app/util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData.test.ts index ba1644660ca0..528014493d89 100644 --- a/app/util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData.test.ts +++ b/app/util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData.test.ts @@ -1,7 +1,12 @@ -import generateUserProfileAnalyticsMetaData from './generateUserProfileAnalyticsMetaData'; +import generateUserProfileAnalyticsMetaData, { + getAccountCompositionTraits, +} from './generateUserProfileAnalyticsMetaData'; import { UserProfileProperty } from './UserProfileAnalyticsMetaData.types'; import { Appearance } from 'react-native'; -import ExtendedKeyringTypes from '../../../constants/keyringTypes'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { KeyringAccountEntropyTypeOption } from '@metamask/keyring-api'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import { createMockInternalAccount } from '../../../util/test/accountsControllerTestUtils'; const mockGetState = jest.fn(); jest.mock('../../../store', () => ({ @@ -22,10 +27,59 @@ jest.mock('../MultichainAPI/networkMetricUtils', () => ({ getConfiguredCaipChainIds: jest.fn(() => mockGetConfiguredCaipChainIds()), })); +// Helper to create an HD account with the new entropy structure +function makeHdAccount( + id: string, + entropyId: string, + groupIndex: number, +): InternalAccount { + return { + ...createMockInternalAccount(`0x${id}`, id, KeyringTypes.hd), + id, + options: { + entropy: { + type: KeyringAccountEntropyTypeOption.Mnemonic, + id: entropyId, + derivationPath: "m/44'/60'/0'/0/0", + groupIndex, + }, + } as InternalAccount['options'], + }; +} + +// Helper to create a non-HD account (imported, hardware, snap) +function makeAccount(id: string, keyringType: KeyringTypes): InternalAccount { + return { + ...createMockInternalAccount(`0x${id}`, id, keyringType), + id, + }; +} + +const mockState = { + engine: { + backgroundState: { + PreferencesController: { + displayNftMedia: true, + useNftDetection: false, + useTokenDetection: true, + isMultiAccountBalancesEnabled: false, + securityAlertsEnabled: true, + }, + AccountsController: { + internalAccounts: { + accounts: {}, + selectedAccount: '', + }, + }, + }, + }, + user: { appTheme: 'os' }, + security: { dataCollectionForMarketing: true }, +}; + describe('generateUserProfileAnalyticsMetaData', () => { beforeEach(() => { jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark'); - mockGetConfiguredCaipChainIds.mockReturnValue(['eip155:1']); }); @@ -33,40 +87,12 @@ describe('generateUserProfileAnalyticsMetaData', () => { jest.clearAllMocks(); }); - const mockState = { - engine: { - backgroundState: { - PreferencesController: { - displayNftMedia: true, - useNftDetection: false, - useTokenDetection: true, - isMultiAccountBalancesEnabled: false, - securityAlertsEnabled: true, - }, - KeyringController: { - keyrings: [ - { - type: ExtendedKeyringTypes.hd, - accounts: ['0x1', '0x2'], - metadata: { - id: '01JPM6NFVGW8V8KKN34053JVFT', - name: '', - }, - }, - ], - }, - }, - }, - user: { appTheme: 'os' }, - security: { dataCollectionForMarketing: true }, - }; - - it('returns metadata', () => { + it('returns metadata with account composition traits', () => { mockGetState.mockReturnValue(mockState); mockIsMetricsEnabled.mockReturnValue(true); const metadata = generateUserProfileAnalyticsMetaData(); - expect(metadata).toEqual({ + expect(metadata).toMatchObject({ [UserProfileProperty.ENABLE_OPENSEA_API]: UserProfileProperty.ON, [UserProfileProperty.NFT_AUTODETECTION]: UserProfileProperty.OFF, [UserProfileProperty.THEME]: 'dark', @@ -75,6 +101,12 @@ describe('generateUserProfileAnalyticsMetaData', () => { [UserProfileProperty.SECURITY_PROVIDERS]: 'blockaid', [UserProfileProperty.HAS_MARKETING_CONSENT]: true, [UserProfileProperty.CHAIN_IDS]: ['eip155:1'], + [UserProfileProperty.NUMBER_OF_HD_ENTROPIES]: 0, + [UserProfileProperty.NUMBER_OF_ACCOUNT_GROUPS]: 0, + [UserProfileProperty.NUMBER_OF_IMPORTED_ACCOUNTS]: 0, + [UserProfileProperty.NUMBER_OF_LEDGER_ACCOUNTS]: 0, + [UserProfileProperty.NUMBER_OF_QR_HARDWARE_ACCOUNTS]: 0, + [UserProfileProperty.NUMBER_OF_HARDWARE_WALLETS]: 0, }); }); @@ -98,8 +130,8 @@ describe('generateUserProfileAnalyticsMetaData', () => { ...mockState, engine: { backgroundState: { - KeyringController: { - keyrings: [], + AccountsController: { + internalAccounts: { accounts: {}, selectedAccount: '' }, }, }, }, @@ -123,3 +155,160 @@ describe('generateUserProfileAnalyticsMetaData', () => { expect(metadata[UserProfileProperty.THEME]).toBe('light'); }); }); + +describe('getAccountCompositionTraits', () => { + it('returns all zeros for empty accounts', () => { + const traits = getAccountCompositionTraits({}); + expect(traits).toEqual({ + [UserProfileProperty.NUMBER_OF_HD_ENTROPIES]: 0, + [UserProfileProperty.NUMBER_OF_ACCOUNT_GROUPS]: 0, + [UserProfileProperty.NUMBER_OF_IMPORTED_ACCOUNTS]: 0, + [UserProfileProperty.NUMBER_OF_LEDGER_ACCOUNTS]: 0, + [UserProfileProperty.NUMBER_OF_QR_HARDWARE_ACCOUNTS]: 0, + [UserProfileProperty.NUMBER_OF_HARDWARE_WALLETS]: 0, + }); + }); + + it('counts a single HD account group correctly', () => { + const acct = makeHdAccount('acct1', 'srp1', 0); + const traits = getAccountCompositionTraits({ [acct.id]: acct }); + expect(traits[UserProfileProperty.NUMBER_OF_HD_ENTROPIES]).toBe(1); + expect(traits[UserProfileProperty.NUMBER_OF_ACCOUNT_GROUPS]).toBe(1); + }); + + it('deduplicates accounts from the same SRP and group index (multichain addresses)', () => { + // Same SRP, same group index → one account group (EVM + Solana addresses for same "account") + const evm = makeHdAccount('evm1', 'srp1', 0); + const sol = makeHdAccount('sol1', 'srp1', 0); + const traits = getAccountCompositionTraits({ + [evm.id]: evm, + [sol.id]: sol, + }); + expect(traits[UserProfileProperty.NUMBER_OF_HD_ENTROPIES]).toBe(1); + expect(traits[UserProfileProperty.NUMBER_OF_ACCOUNT_GROUPS]).toBe(1); + }); + + it('counts separate group indexes as separate account groups', () => { + // Same SRP, different group indexes → two account groups + const acct1 = makeHdAccount('acct1', 'srp1', 0); + const acct2 = makeHdAccount('acct2', 'srp1', 1); + const traits = getAccountCompositionTraits({ + [acct1.id]: acct1, + [acct2.id]: acct2, + }); + expect(traits[UserProfileProperty.NUMBER_OF_HD_ENTROPIES]).toBe(1); + expect(traits[UserProfileProperty.NUMBER_OF_ACCOUNT_GROUPS]).toBe(2); + }); + + it('counts multiple SRPs correctly', () => { + const acct1 = makeHdAccount('acct1', 'srp1', 0); + const acct2 = makeHdAccount('acct2', 'srp2', 0); + const traits = getAccountCompositionTraits({ + [acct1.id]: acct1, + [acct2.id]: acct2, + }); + expect(traits[UserProfileProperty.NUMBER_OF_HD_ENTROPIES]).toBe(2); + expect(traits[UserProfileProperty.NUMBER_OF_ACCOUNT_GROUPS]).toBe(2); + }); + + it('counts imported accounts', () => { + const acct = makeAccount('imported1', KeyringTypes.simple); + const traits = getAccountCompositionTraits({ [acct.id]: acct }); + expect(traits[UserProfileProperty.NUMBER_OF_IMPORTED_ACCOUNTS]).toBe(1); + expect(traits[UserProfileProperty.NUMBER_OF_ACCOUNT_GROUPS]).toBe(1); + expect(traits[UserProfileProperty.NUMBER_OF_HD_ENTROPIES]).toBe(0); + expect(traits[UserProfileProperty.NUMBER_OF_HARDWARE_WALLETS]).toBe(0); + }); + + it('counts Ledger accounts and hardware wallets', () => { + const acct = makeAccount('ledger1', KeyringTypes.ledger); + const traits = getAccountCompositionTraits({ [acct.id]: acct }); + expect(traits[UserProfileProperty.NUMBER_OF_LEDGER_ACCOUNTS]).toBe(1); + expect(traits[UserProfileProperty.NUMBER_OF_HARDWARE_WALLETS]).toBe(1); + expect(traits[UserProfileProperty.NUMBER_OF_HD_ENTROPIES]).toBe(0); + }); + + it('counts QR hardware accounts (qr keyring)', () => { + const acct = makeAccount('qr1', KeyringTypes.qr); + const traits = getAccountCompositionTraits({ [acct.id]: acct }); + expect(traits[UserProfileProperty.NUMBER_OF_QR_HARDWARE_ACCOUNTS]).toBe(1); + expect(traits[UserProfileProperty.NUMBER_OF_HARDWARE_WALLETS]).toBe(1); + }); + + it('counts QR hardware accounts (oneKey keyring)', () => { + const acct = makeAccount('onekey1', KeyringTypes.oneKey); + const traits = getAccountCompositionTraits({ [acct.id]: acct }); + expect(traits[UserProfileProperty.NUMBER_OF_QR_HARDWARE_ACCOUNTS]).toBe(1); + expect(traits[UserProfileProperty.NUMBER_OF_HARDWARE_WALLETS]).toBe(1); + }); + + it('number_of_hardware_wallets counts device types, not total accounts (1 Ledger + 1 QR = 2)', () => { + const ledger = makeAccount('ledger1', KeyringTypes.ledger); + const qr = makeAccount('qr1', KeyringTypes.qr); + const traits = getAccountCompositionTraits({ + [ledger.id]: ledger, + [qr.id]: qr, + }); + expect(traits[UserProfileProperty.NUMBER_OF_HARDWARE_WALLETS]).toBe(2); + }); + + it('multiple accounts of the same hardware type count as 1 wallet (3 Ledger = 1)', () => { + const ledger1 = makeAccount('ledger1', KeyringTypes.ledger); + const ledger2 = makeAccount('ledger2', KeyringTypes.ledger); + const ledger3 = makeAccount('ledger3', KeyringTypes.ledger); + const traits = getAccountCompositionTraits({ + [ledger1.id]: ledger1, + [ledger2.id]: ledger2, + [ledger3.id]: ledger3, + }); + expect(traits[UserProfileProperty.NUMBER_OF_LEDGER_ACCOUNTS]).toBe(3); + expect(traits[UserProfileProperty.NUMBER_OF_HARDWARE_WALLETS]).toBe(1); + }); + + it('hardware wallet accounts do not contribute to number_of_hd_entropies', () => { + const ledger = makeAccount('ledger1', KeyringTypes.ledger); + const qr = makeAccount('qr1', KeyringTypes.qr); + const traits = getAccountCompositionTraits({ + [ledger.id]: ledger, + [qr.id]: qr, + }); + expect(traits[UserProfileProperty.NUMBER_OF_HD_ENTROPIES]).toBe(0); + }); + + it('handles mixed wallet composition', () => { + const hdEvm = makeHdAccount('hd-evm', 'srp1', 0); + const hdSol = makeHdAccount('hd-sol', 'srp1', 0); + const hdEvm2 = makeHdAccount('hd-evm2', 'srp1', 1); + const imported = makeAccount('imported', KeyringTypes.simple); + const ledger = makeAccount('ledger', KeyringTypes.ledger); + + const traits = getAccountCompositionTraits({ + [hdEvm.id]: hdEvm, + [hdSol.id]: hdSol, + [hdEvm2.id]: hdEvm2, + [imported.id]: imported, + [ledger.id]: ledger, + }); + + expect(traits[UserProfileProperty.NUMBER_OF_HD_ENTROPIES]).toBe(1); + expect(traits[UserProfileProperty.NUMBER_OF_ACCOUNT_GROUPS]).toBe(4); // srp1:0, srp1:1, imported, ledger + expect(traits[UserProfileProperty.NUMBER_OF_IMPORTED_ACCOUNTS]).toBe(1); + expect(traits[UserProfileProperty.NUMBER_OF_LEDGER_ACCOUNTS]).toBe(1); + expect(traits[UserProfileProperty.NUMBER_OF_HARDWARE_WALLETS]).toBe(1); + }); + + it('treats accounts without entropy structure as individual groups', () => { + const noEntropy = { + ...createMockInternalAccount( + '0xunknown1', + 'unknown', + 'Unknown Keyring Type' as KeyringTypes, + ), + id: 'unknown1', + }; + + const traits = getAccountCompositionTraits({ [noEntropy.id]: noEntropy }); + expect(traits[UserProfileProperty.NUMBER_OF_ACCOUNT_GROUPS]).toBe(1); + expect(traits[UserProfileProperty.NUMBER_OF_HD_ENTROPIES]).toBe(0); + }); +}); diff --git a/app/util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData.ts b/app/util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData.ts index 519fef9565db..a4a8ad5739eb 100644 --- a/app/util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData.ts +++ b/app/util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData.ts @@ -1,9 +1,75 @@ import { Appearance } from 'react-native'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { KeyringAccountEntropyTypeOption } from '@metamask/keyring-api'; +import { InternalAccount } from '@metamask/keyring-internal-api'; import { store } from '../../../store'; import { UserProfileProperty } from './UserProfileAnalyticsMetaData.types'; import { getConfiguredCaipChainIds } from '../MultichainAPI/networkMetricUtils'; import type { AnalyticsUserTraits } from '@metamask/analytics-controller'; +/** + * Computes wallet composition traits from internalAccounts. + * Uses AccountsController as the source of truth so traits remain accurate when the wallet is locked. + */ +export function getAccountCompositionTraits( + internalAccounts: Record, +): AnalyticsUserTraits { + const accountGroupKeys = new Set(); + const hdEntropyIds = new Set(); + let numberOfImportedAccounts = 0; + let numberOfLedgerAccounts = 0; + let numberOfQrHardwareAccounts = 0; + + for (const [accountId, account] of Object.entries(internalAccounts)) { + const keyringType = account.metadata?.keyring?.type; + + switch (keyringType) { + case KeyringTypes.simple: + numberOfImportedAccounts += 1; + break; + case KeyringTypes.ledger: + numberOfLedgerAccounts += 1; + break; + case KeyringTypes.qr: + case KeyringTypes.oneKey: + numberOfQrHardwareAccounts += 1; + break; + default: + break; + } + + // BIP44 multichain accounts share an entropy id and group index across all chains. + // Deduplicating on that composite key counts account groups rather than individual addresses. + const entropy: InternalAccount['options']['entropy'] = + account.options?.entropy; + + if ( + entropy?.type === KeyringAccountEntropyTypeOption.Mnemonic && + entropy.id && + entropy.groupIndex !== undefined + ) { + accountGroupKeys.add(`${entropy.id}:${entropy.groupIndex}`); + hdEntropyIds.add(entropy.id); + } else { + accountGroupKeys.add(accountId); + } + } + + const numberOfHardwareWallets = + (numberOfLedgerAccounts > 0 ? 1 : 0) + + (numberOfQrHardwareAccounts > 0 ? 1 : 0); + + return { + [UserProfileProperty.NUMBER_OF_HD_ENTROPIES]: hdEntropyIds.size, + [UserProfileProperty.NUMBER_OF_ACCOUNT_GROUPS]: accountGroupKeys.size, + [UserProfileProperty.NUMBER_OF_IMPORTED_ACCOUNTS]: numberOfImportedAccounts, + [UserProfileProperty.NUMBER_OF_LEDGER_ACCOUNTS]: numberOfLedgerAccounts, + [UserProfileProperty.NUMBER_OF_QR_HARDWARE_ACCOUNTS]: + numberOfQrHardwareAccounts, + [UserProfileProperty.NUMBER_OF_HARDWARE_WALLETS]: numberOfHardwareWallets, + }; +} + /** * Generate user profile analytics meta data * To be used in the Segment identify call @@ -22,6 +88,10 @@ const generateUserProfileAnalyticsMetaData = (): AnalyticsUserTraits => { const chainIds = getConfiguredCaipChainIds(); + const internalAccounts = + reduxState?.engine?.backgroundState?.AccountsController?.internalAccounts + ?.accounts ?? {}; + const traits: AnalyticsUserTraits = { [UserProfileProperty.ENABLE_OPENSEA_API]: preferencesController?.displayNftMedia @@ -46,6 +116,7 @@ const generateUserProfileAnalyticsMetaData = (): AnalyticsUserTraits => { isDataCollectionForMarketingEnabled, ), [UserProfileProperty.CHAIN_IDS]: chainIds, + ...getAccountCompositionTraits(internalAccounts), }; return traits; };