diff --git a/app/scripts/controllers/metametrics-controller.test.ts b/app/scripts/controllers/metametrics-controller.test.ts index 6745cead0886..26fea2c22da5 100644 --- a/app/scripts/controllers/metametrics-controller.test.ts +++ b/app/scripts/controllers/metametrics-controller.test.ts @@ -10,6 +10,11 @@ import { Token, TokensControllerState, } from '@metamask/assets-controllers'; +import { + EthAccountType, + BtcAccountType, + SolAccountType, +} from '@metamask/keyring-api'; import { InternalAccount } from '@metamask/keyring-internal-api'; import { Browser } from 'webextension-polyfill'; import { deriveStateFromMetadata } from '@metamask/base-controller'; @@ -46,6 +51,10 @@ import * as ManifestFlags from '../../../shared/lib/manifestFlags'; import * as Utils from '../lib/util'; import { mockNetworkState } from '../../../test/stub/networks'; import { flushPromises } from '../../../test/lib/timer-helpers'; +import { + createMockInternalAccount, + createMockInternalAccounts, +} from '../../../test/data/mock-accounts'; import { MetaMetricsController, AllowedActions, @@ -1852,6 +1861,75 @@ describe('MetaMetricsController', function () { }); }); + function buildStateWithAccounts( + accounts: Record, + ): Parameters[0] { + return { + addressBook: {}, + allNfts: {}, + allTokens: {}, + ...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }), + internalAccounts: { + accounts, + selectedAccount: Object.keys(accounts)[0] ?? '', + }, + multichainNetworkConfigurationsByChainId: {}, + ledgerTransportType: LedgerTransportTypes.webhid, + openSeaEnabled: false, + useNftDetection: false, + theme: 'default' as ThemeType, + useTokenDetection: false, + names: { + [NameType.ETHEREUM_ADDRESS]: {}, + }, + currentCurrency: 'usd', + securityAlertsEnabled: false, + participateInMetaMetrics: true, + dataCollectionForMarketing: false, + preferences: { + privacyMode: false, + tokenNetworkFilter: {}, + tokenSortConfig: { + key: '', + order: 'dsc', + sortCallback: 'stringNumeric', + }, + showNativeTokenAsMainBalance: false, + } as Preferences, + srpSessionData: undefined, + keyrings: [], + }; + } + + const buildKeyringAccount = (id: string, keyringType: string) => ({ + id, + metadata: { keyring: { type: keyringType } }, + }); + + const buildMnemonicEntropyAccount = ({ + id, + entropyId, + groupIndex, + derivationPath, + keyringType = KeyringType.hdKeyTree, + }: { + id: string; + entropyId: string; + groupIndex: number; + derivationPath?: string; + keyringType?: string; + }) => ({ + ...buildKeyringAccount(id, keyringType), + options: { + entropy: { + type: 'mnemonic' as const, + id: entropyId, + groupIndex, + ...(derivationPath ? { derivationPath } : {}), + }, + }, + }); + describe('_buildUserTraitsObject', function () { beforeEach(() => { jest.spyOn(Utils, 'getPlatform').mockReturnValue(PLATFORM_CHROME); @@ -2037,6 +2115,13 @@ describe('MetaMetricsController', function () { [MetaMetricsUserTrait.NumberOfNfts]: 4, [MetaMetricsUserTrait.NumberOfTokens]: 5, [MetaMetricsUserTrait.NumberOfHDEntropies]: 0, + [MetaMetricsUserTrait.NumberOfAccountGroups]: 2, + [MetaMetricsUserTrait.NumberOfImportedAccounts]: 0, + [MetaMetricsUserTrait.NumberOfLedgerAccounts]: 0, + [MetaMetricsUserTrait.NumberOfTrezorAccounts]: 0, + [MetaMetricsUserTrait.NumberOfLatticeAccounts]: 0, + [MetaMetricsUserTrait.NumberOfQrHardwareAccounts]: 0, + [MetaMetricsUserTrait.NumberOfHardwareWallets]: 0, [MetaMetricsUserTrait.OpenSeaApiEnabled]: true, [MetaMetricsUserTrait.ThreeBoxEnabled]: false, [MetaMetricsUserTrait.Theme]: 'default', @@ -2195,6 +2280,7 @@ describe('MetaMetricsController', function () { expect(updatedTraits).toStrictEqual({ [MetaMetricsUserTrait.AddressBookEntries]: 4, [MetaMetricsUserTrait.NumberOfAccounts]: 3, + [MetaMetricsUserTrait.NumberOfAccountGroups]: 3, [MetaMetricsUserTrait.NumberOfTokens]: 1, [MetaMetricsUserTrait.OpenSeaApiEnabled]: false, [MetaMetricsUserTrait.ShowNativeTokenAsMainBalance]: false, @@ -2341,6 +2427,129 @@ describe('MetaMetricsController', function () { expect(updatedTraits).toStrictEqual(null); }); }); + + it('should count BIP44 multichain accounts as one account group per entropy+index pair', async function () { + const srp1 = 'entropy-source-id-1'; + function mockBip44Account( + id: string, + type: InternalAccount['type'], + keyringType: InternalAccount['metadata']['keyring']['type'], + groupIndex: number, + ) { + return createMockInternalAccount({ + id, + type, + metadata: { keyring: { type: keyringType } }, + options: { + entropy: { + type: 'mnemonic', + id: srp1, + groupIndex, + derivationPath: '', + }, + }, + }); + } + + // 2 account groups from 1 SRP, each with EVM + BTC + SOL addresses. + const evm0 = mockBip44Account( + 'evm-0', + EthAccountType.Eoa, + KeyringType.hdKeyTree, + 0, + ); + const btc0 = mockBip44Account( + 'btc-0', + BtcAccountType.P2wpkh, + KeyringType.snap, + 0, + ); + const sol0 = mockBip44Account( + 'sol-0', + SolAccountType.DataAccount, + KeyringType.snap, + 0, + ); + const evm1 = mockBip44Account( + 'evm-1', + EthAccountType.Eoa, + KeyringType.hdKeyTree, + 1, + ); + const btc1 = mockBip44Account( + 'btc-1', + BtcAccountType.P2wpkh, + KeyringType.snap, + 1, + ); + const sol1 = mockBip44Account( + 'sol-1', + SolAccountType.DataAccount, + KeyringType.snap, + 1, + ); + + const mockAccounts: Record = { + [evm0.id]: evm0, + [btc0.id]: btc0, + [sol0.id]: sol0, + [evm1.id]: evm1, + [btc1.id]: btc1, + [sol1.id]: sol1, + }; + await withController(({ controller }) => { + const traits = controller._buildUserTraitsObject( + buildStateWithAccounts(mockAccounts), + ); + + // 6 internal accounts but only 2 unique {srp, groupIndex} pairs → 2 groups. + expect(traits?.[MetaMetricsUserTrait.NumberOfAccountGroups]).toBe(2); + expect(traits?.[MetaMetricsUserTrait.NumberOfImportedAccounts]).toBe(0); + expect(traits?.[MetaMetricsUserTrait.NumberOfLedgerAccounts]).toBe(0); + // 1 unique entropy id → 1 HD entropy. + expect(traits?.[MetaMetricsUserTrait.NumberOfHDEntropies]).toBe(1); + expect(traits?.[MetaMetricsUserTrait.NumberOfHardwareWallets]).toBe(0); + }); + }); + + it('should correctly count imported and hardware wallet account types', async function () { + const mockAccounts = createMockInternalAccounts([ + buildMnemonicEntropyAccount({ + id: 'hd-acc', + entropyId: 'srp1', + groupIndex: 0, + derivationPath: "m/44'/60'/0'/0/0", + }), + buildKeyringAccount('imported-acc', KeyringType.imported), + buildKeyringAccount('snap-acc', KeyringType.snap), + buildKeyringAccount('ledger-acc', KeyringType.ledger), + buildKeyringAccount('trezor-acc', KeyringType.trezor), + buildKeyringAccount('lattice-acc', KeyringType.lattice), + buildKeyringAccount('qr-acc', KeyringType.qr), + buildKeyringAccount('onekey-acc', KeyringType.oneKey), + ]); + await withController(({ controller }) => { + const traits = controller._buildUserTraitsObject( + buildStateWithAccounts(mockAccounts), + ); + + // 8 accounts: 1 HD group + 1 imported + 1 snap + 1 ledger + + // 1 trezor + 1 lattice + 1 qr + 1 onekey = 8 distinct groups. + expect(traits?.[MetaMetricsUserTrait.NumberOfAccountGroups]).toBe(8); + expect(traits?.[MetaMetricsUserTrait.NumberOfImportedAccounts]).toBe(1); + expect(traits?.[MetaMetricsUserTrait.NumberOfLedgerAccounts]).toBe(1); + expect(traits?.[MetaMetricsUserTrait.NumberOfTrezorAccounts]).toBe(1); + expect(traits?.[MetaMetricsUserTrait.NumberOfLatticeAccounts]).toBe(1); + // QR hardware includes both 'QR Hardware Wallet Device' and 'OneKey Hardware'. + expect(traits?.[MetaMetricsUserTrait.NumberOfQrHardwareAccounts]).toBe( + 2, + ); + // 1 mnemonic entropy id → 1 HD entropy; hardware wallets don't contribute. + expect(traits?.[MetaMetricsUserTrait.NumberOfHDEntropies]).toBe(1); + // 1 of each type paired → 4 distinct hardware wallets (one per type). + expect(traits?.[MetaMetricsUserTrait.NumberOfHardwareWallets]).toBe(4); + }); + }); }); describe('submitting segmentApiCalls to segment SDK', function () { @@ -2491,59 +2700,208 @@ describe('MetaMetricsController', function () { }); }); - describe('getNumberOfHDEntropies', function () { - it('counts HD entropies correctly across various keyring configurations', async function () { - await withController(({ controller: _ }) => { - const testScenarios = [ - { - name: 'with undefined keyrings', - state: { keyrings: undefined }, - expectedCount: 0, - }, - { - name: 'with empty keyrings array', - state: { keyrings: [] }, - expectedCount: 0, - }, - { - name: 'with one HD keyring', - state: { - keyrings: [{ type: KeyringType.hdKeyTree, accounts: ['0x123'] }], - }, - expectedCount: 1, - }, - { - name: 'with two HD keyrings', - state: { - keyrings: [ - { type: KeyringType.hdKeyTree, accounts: ['0x123'] }, - { type: KeyringType.hdKeyTree, accounts: ['0x456'] }, - ], - }, - expectedCount: 2, - }, - { - name: 'with mixed keyring types', - state: { - keyrings: [ - { type: KeyringType.hdKeyTree, accounts: ['0x123'] }, - { type: KeyringType.imported, accounts: ['0x456'] }, - { type: KeyringType.ledger, accounts: ['0x789'] }, - { type: KeyringType.hdKeyTree, accounts: ['0xabc'] }, - ], - }, - expectedCount: 2, - }, - ]; + describe('#getAccountCompositionTraits', function () { + it('returns zeros for an empty accounts object', async function () { + await withController(({ controller }) => { + const traits = controller._buildUserTraitsObject( + buildStateWithAccounts({}), + ); + expect(traits?.[MetaMetricsUserTrait.NumberOfHDEntropies]).toBe(0); + expect(traits?.[MetaMetricsUserTrait.NumberOfHardwareWallets]).toBe(0); + expect(traits?.[MetaMetricsUserTrait.NumberOfAccountGroups]).toBe(0); + expect(traits?.[MetaMetricsUserTrait.NumberOfImportedAccounts]).toBe(0); + expect(traits?.[MetaMetricsUserTrait.NumberOfLedgerAccounts]).toBe(0); + expect(traits?.[MetaMetricsUserTrait.NumberOfTrezorAccounts]).toBe(0); + expect(traits?.[MetaMetricsUserTrait.NumberOfLatticeAccounts]).toBe(0); + expect(traits?.[MetaMetricsUserTrait.NumberOfQrHardwareAccounts]).toBe( + 0, + ); + }); + }); - testScenarios.forEach((scenario) => { - // Implement the same logic as the private method - const hdKeyringCount = - scenario.state.keyrings?.filter( - (keyring) => keyring.type === KeyringType.hdKeyTree, - ).length ?? 0; - expect(hdKeyringCount).toBe(scenario.expectedCount); - }); + it('counts a single SRP with multiple account groups as one HD entropy', async function () { + await withController(({ controller }) => { + const traits = controller._buildUserTraitsObject( + buildStateWithAccounts( + createMockInternalAccounts([ + buildMnemonicEntropyAccount({ + id: 'evm-0', + entropyId: 'srp1', + groupIndex: 0, + }), + buildMnemonicEntropyAccount({ + id: 'evm-1', + entropyId: 'srp1', + groupIndex: 1, + }), + buildMnemonicEntropyAccount({ + id: 'evm-2', + entropyId: 'srp1', + groupIndex: 2, + }), + ]), + ), + ); + expect(traits?.[MetaMetricsUserTrait.NumberOfHDEntropies]).toBe(1); + expect(traits?.[MetaMetricsUserTrait.NumberOfAccountGroups]).toBe(3); + expect(traits?.[MetaMetricsUserTrait.NumberOfHardwareWallets]).toBe(0); + }); + }); + + it('counts multiple distinct SRPs as separate HD entropies', async function () { + await withController(({ controller }) => { + const traits = controller._buildUserTraitsObject( + buildStateWithAccounts( + createMockInternalAccounts([ + buildMnemonicEntropyAccount({ + id: 'evm-srp1', + entropyId: 'srp1', + groupIndex: 0, + }), + buildMnemonicEntropyAccount({ + id: 'evm-srp2', + entropyId: 'srp2', + groupIndex: 0, + }), + buildMnemonicEntropyAccount({ + id: 'evm-srp3', + entropyId: 'srp3', + groupIndex: 0, + }), + ]), + ), + ); + expect(traits?.[MetaMetricsUserTrait.NumberOfHDEntropies]).toBe(3); + expect(traits?.[MetaMetricsUserTrait.NumberOfAccountGroups]).toBe(3); + }); + }); + + it('does not count hardware wallets toward HD entropies', async function () { + await withController(({ controller }) => { + const traits = controller._buildUserTraitsObject( + buildStateWithAccounts( + createMockInternalAccounts([ + buildKeyringAccount('ledger-1', KeyringType.ledger), + buildKeyringAccount('ledger-2', KeyringType.ledger), + buildKeyringAccount('trezor-1', KeyringType.trezor), + buildKeyringAccount('lattice-1', KeyringType.lattice), + buildKeyringAccount('qr-1', KeyringType.qr), + buildKeyringAccount('onekey-1', KeyringType.oneKey), + ]), + ), + ); + expect(traits?.[MetaMetricsUserTrait.NumberOfHDEntropies]).toBe(0); + expect(traits?.[MetaMetricsUserTrait.NumberOfLedgerAccounts]).toBe(2); + expect(traits?.[MetaMetricsUserTrait.NumberOfTrezorAccounts]).toBe(1); + expect(traits?.[MetaMetricsUserTrait.NumberOfLatticeAccounts]).toBe(1); + expect(traits?.[MetaMetricsUserTrait.NumberOfQrHardwareAccounts]).toBe( + 2, + ); + // 1 Ledger device + 1 Trezor + 1 Lattice + 1 QR (OneKey) = 4 distinct hardware wallets. + expect(traits?.[MetaMetricsUserTrait.NumberOfHardwareWallets]).toBe(4); + }); + }); + + it('does not count imported accounts toward HD entropies or hardware wallets', async function () { + await withController(({ controller }) => { + const traits = controller._buildUserTraitsObject( + buildStateWithAccounts( + createMockInternalAccounts([ + buildKeyringAccount('imported-1', KeyringType.imported), + buildKeyringAccount('imported-2', KeyringType.imported), + ]), + ), + ); + expect(traits?.[MetaMetricsUserTrait.NumberOfHDEntropies]).toBe(0); + expect(traits?.[MetaMetricsUserTrait.NumberOfHardwareWallets]).toBe(0); + expect(traits?.[MetaMetricsUserTrait.NumberOfImportedAccounts]).toBe(2); + }); + }); + + it('computes number_of_hardware_wallets as the sum of all hardware wallet types', async function () { + await withController(({ controller }) => { + const traits = controller._buildUserTraitsObject( + buildStateWithAccounts( + createMockInternalAccounts([ + buildKeyringAccount('ledger-1', KeyringType.ledger), + buildKeyringAccount('ledger-2', KeyringType.ledger), + buildKeyringAccount('trezor-1', KeyringType.trezor), + buildKeyringAccount('lattice-1', KeyringType.lattice), + buildKeyringAccount('lattice-2', KeyringType.lattice), + buildKeyringAccount('qr-1', KeyringType.qr), + buildKeyringAccount('onekey-1', KeyringType.oneKey), + ]), + ), + ); + // 1 Ledger device + 1 Trezor + 1 Lattice + 1 QR (includes OneKey) = 4 distinct hardware wallets. + expect(traits?.[MetaMetricsUserTrait.NumberOfHardwareWallets]).toBe(4); + }); + }); + + it('handles accounts with unknown keyring type without throwing', async function () { + await withController(({ controller }) => { + expect(() => + controller._buildUserTraitsObject( + buildStateWithAccounts( + createMockInternalAccounts([ + buildKeyringAccount('unknown-acc', 'SomeUnknownKeyring'), + ]), + ), + ), + ).not.toThrow(); + }); + }); + + it('handles accounts with missing metadata without throwing', async function () { + await withController(({ controller }) => { + expect(() => + controller._buildUserTraitsObject( + buildStateWithAccounts( + createMockInternalAccounts([ + { + ...buildKeyringAccount( + 'no-metadata-acc', + KeyringType.hdKeyTree, + ), + metadata: undefined, + }, + ]), + ), + ), + ).not.toThrow(); + }); + }); + + it('derives total wallets from hd_entropies + hardware_wallets + imported_accounts', async function () { + await withController(({ controller }) => { + const traits = controller._buildUserTraitsObject( + buildStateWithAccounts( + createMockInternalAccounts([ + buildMnemonicEntropyAccount({ + id: 'evm-0', + entropyId: 'srp1', + groupIndex: 0, + }), + buildMnemonicEntropyAccount({ + id: 'evm-1', + entropyId: 'srp2', + groupIndex: 0, + }), + buildKeyringAccount('ledger-1', KeyringType.ledger), + buildKeyringAccount('imported-1', KeyringType.imported), + // Snap accounts are excluded from total wallet count. + buildKeyringAccount('snap-1', KeyringType.snap), + ]), + ), + ); + const hdEntropies = + traits?.[MetaMetricsUserTrait.NumberOfHDEntropies] ?? 0; + const hardwareWallets = + traits?.[MetaMetricsUserTrait.NumberOfHardwareWallets] ?? 0; + const importedAccounts = + traits?.[MetaMetricsUserTrait.NumberOfImportedAccounts] ?? 0; + // 2 SRPs + 1 hardware + 1 imported = 4 total wallets. + expect(hdEntropies + hardwareWallets + importedAccounts).toBe(4); }); }); }); diff --git a/app/scripts/controllers/metametrics-controller.ts b/app/scripts/controllers/metametrics-controller.ts index b2d01609334a..a374f4c9f130 100644 --- a/app/scripts/controllers/metametrics-controller.ts +++ b/app/scripts/controllers/metametrics-controller.ts @@ -34,6 +34,7 @@ import { } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import type { Json, Hex } from '@metamask/utils'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; import { ENVIRONMENT_TYPE_BACKGROUND, PLATFORM_FIREFOX, @@ -1441,9 +1442,6 @@ export class MetaMetricsController extends BaseController< [MetaMetricsUserTrait.NumberOfTokens]: this.#getNumberOfTokens( getTokensControllerAllTokens({ metamask: metamaskState }), ), - [MetaMetricsUserTrait.NumberOfHDEntropies]: - this.#getNumberOfHDEntropies(metamaskState) ?? - this.previousUserTraits?.number_of_hd_entropies, [MetaMetricsUserTrait.OpenSeaApiEnabled]: metamaskState.openSeaEnabled, [MetaMetricsUserTrait.ThreeBoxEnabled]: false, // deprecated, hard-coded as false [MetaMetricsUserTrait.Theme]: metamaskState.theme || 'default', @@ -1474,6 +1472,7 @@ export class MetaMetricsController extends BaseController< [MetaMetricsUserTrait.InstallType]: getInstallType(), [MetaMetricsUserTrait.DeviceType]: getDeviceType(), [MetaMetricsUserTrait.Os]: getOs(), + ...this.#getAccountCompositionTraits(metamaskState), }; if (!this.previousUserTraits && metamaskState.participateInMetaMetrics) { @@ -1566,16 +1565,88 @@ export class MetaMetricsController extends BaseController< } /** - * Returns the number of HD Entropies the user has. + * Computes wallet composition traits from internalAccounts, which is always + * available regardless of lock state (unlike keyrings). + * + * number_of_account_groups deduplicates BIP44 multichain accounts by their + * entropy source and group index so that EVM + BTC + SOL addresses derived + * from the same SRP slot count as one account group, matching what users see + * in the Account Management UI. * * @param metamaskState */ - #getNumberOfHDEntropies(metamaskState: MetaMaskState): number { - return ( - metamaskState.keyrings?.filter( - (keyring) => keyring.type === KeyringType.hdKeyTree, - ).length ?? 0 - ); + #getAccountCompositionTraits( + metamaskState: MetaMaskState, + ): Partial { + const accountGroupKeys = new Set(); + const hdEntropyIds = new Set(); + let numberOfImportedAccounts = 0; + let numberOfLedgerAccounts = 0; + let numberOfTrezorAccounts = 0; + let numberOfLatticeAccounts = 0; + let numberOfQrHardwareAccounts = 0; + + for (const [accountId, account] of Object.entries( + metamaskState.internalAccounts.accounts, + )) { + const keyringType = account.metadata?.keyring?.type; + + switch (keyringType) { + case KeyringType.imported: + numberOfImportedAccounts += 1; + break; + case KeyringType.ledger: + numberOfLedgerAccounts += 1; + break; + case KeyringType.trezor: + numberOfTrezorAccounts += 1; + break; + case KeyringType.lattice: + numberOfLatticeAccounts += 1; + break; + case KeyringType.qr: + case KeyringType.oneKey: + numberOfQrHardwareAccounts += 1; + break; + default: + break; + } + + // BIP44 multichain accounts share an entropy source id and group index + // across all chains (EVM, BTC, SOL, …). Deduplicating on that key gives + // the count of account groups rather than individual chain addresses. + const entropy: InternalAccount['options']['entropy'] = + account.options?.entropy; + + if ( + entropy?.type === 'mnemonic' && + 'id' in entropy && + 'groupIndex' in entropy + ) { + accountGroupKeys.add(`${entropy.id}:${entropy.groupIndex}`); + hdEntropyIds.add(entropy.id); + } else { + accountGroupKeys.add(accountId); + } + } + + return { + [MetaMetricsUserTrait.NumberOfHDEntropies]: hdEntropyIds.size, + [MetaMetricsUserTrait.NumberOfAccountGroups]: accountGroupKeys.size, + [MetaMetricsUserTrait.NumberOfImportedAccounts]: numberOfImportedAccounts, + [MetaMetricsUserTrait.NumberOfLedgerAccounts]: numberOfLedgerAccounts, + [MetaMetricsUserTrait.NumberOfTrezorAccounts]: numberOfTrezorAccounts, + [MetaMetricsUserTrait.NumberOfLatticeAccounts]: numberOfLatticeAccounts, + [MetaMetricsUserTrait.NumberOfQrHardwareAccounts]: + numberOfQrHardwareAccounts, + // MetaMask enforces one paired device per hardware wallet type, so + // "types in use" equals "distinct devices". + [MetaMetricsUserTrait.NumberOfHardwareWallets]: + (numberOfLedgerAccounts > 0 ? 1 : 0) + + (numberOfTrezorAccounts > 0 ? 1 : 0) + + (numberOfLatticeAccounts > 0 ? 1 : 0) + + (numberOfQrHardwareAccounts > 0 ? 1 : 0), + }; } /** diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index da88338c9b6d..de404a9173eb 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -591,6 +591,52 @@ export type MetaMetricsUserTraits = { * The operating system (normalized). */ os?: Os; + /** + * Total BIP44 account groups across all keyring types — what users perceive + * as "accounts" in the Account Management UI. + */ + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + number_of_account_groups?: number; + /** + * Number of accounts added via raw private key (Simple Key Pair keyring). + */ + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + number_of_imported_accounts?: number; + /** + * Number of account groups from Ledger hardware wallet. + */ + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + number_of_ledger_accounts?: number; + /** + * Number of account groups from Trezor hardware wallet. + */ + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + number_of_trezor_accounts?: number; + /** + * Number of account groups from Lattice (GridPlus) hardware wallet. + */ + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + number_of_lattice_accounts?: number; + /** + * Number of account groups from QR-based hardware wallets (OneKey, Keystone, + * AirGap Vault, CoolWallet, DCent, Ngrave, imToken, Keycard Shell). + */ + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + number_of_qr_hardware_accounts?: number; + /** + * Total number of hardware wallet accounts across all hardware wallet types + * (Ledger, Trezor, Lattice, QR-based). Combined with number_of_hd_entropies + * and number_of_imported_accounts, gives the total number of wallets. + */ + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + number_of_hardware_wallets?: number; }; export enum MetaMetricsUserTrait { @@ -725,6 +771,38 @@ export enum MetaMetricsUserTrait { * The operating system (normalized). */ Os = 'os', + /** + * Total BIP44 account groups across all keyring types — what users perceive + * as "accounts" in the Account Management UI. + */ + NumberOfAccountGroups = 'number_of_account_groups', + /** + * Number of accounts added via raw private key (Simple Key Pair keyring). + */ + NumberOfImportedAccounts = 'number_of_imported_accounts', + /** + * Number of account groups from Ledger hardware wallet. + */ + NumberOfLedgerAccounts = 'number_of_ledger_accounts', + /** + * Number of account groups from Trezor hardware wallet. + */ + NumberOfTrezorAccounts = 'number_of_trezor_accounts', + /** + * Number of account groups from Lattice (GridPlus) hardware wallet. + */ + NumberOfLatticeAccounts = 'number_of_lattice_accounts', + /** + * Number of account groups from QR-based hardware wallets (OneKey, Keystone, + * AirGap Vault, CoolWallet, DCent, Ngrave, imToken, Keycard Shell). + */ + NumberOfQrHardwareAccounts = 'number_of_qr_hardware_accounts', + /** + * Total number of hardware wallet accounts across all hardware wallet types + * (Ledger, Trezor, Lattice, QR-based). Combined with number_of_hd_entropies + * and number_of_imported_accounts, gives the total number of wallets. + */ + NumberOfHardwareWallets = 'number_of_hardware_wallets', } /** diff --git a/test/data/mock-accounts.ts b/test/data/mock-accounts.ts index afb180da2205..f4db885920c3 100644 --- a/test/data/mock-accounts.ts +++ b/test/data/mock-accounts.ts @@ -242,3 +242,84 @@ export const MOCK_ACCOUNT_ID_BY_ADDRESS = { [MOCK_ACCOUNT_HARDWARE.address]: MOCK_ACCOUNT_HARDWARE.id, [MOCK_ACCOUNT_PRIVATE_KEY.address]: MOCK_ACCOUNT_PRIVATE_KEY.id, }; + +type MockInternalAccountOptions = Omit< + InternalAccount['options'], + 'entropy' +> & { + entropy?: + | { + type: 'mnemonic'; + id: string; + groupIndex: number; + derivationPath?: string; + } + | { type: 'private-key' } + | { type: 'custom' }; +}; + +type MockInternalAccountOverrides = Omit< + Partial, + 'metadata' | 'options' +> & + Pick & { + metadata?: Partial; + options?: MockInternalAccountOptions; + }; + +function normalizeOptions( + options?: MockInternalAccountOptions, +): InternalAccount['options'] { + if (!options?.entropy) { + return (options ?? {}) as InternalAccount['options']; + } + + if (options.entropy.type !== 'mnemonic') { + return options as InternalAccount['options']; + } + + return { + ...options, + entropy: { + ...options.entropy, + derivationPath: options.entropy.derivationPath ?? '', + }, + }; +} + +export function createMockInternalAccount({ + id, + address, + metadata, + options, + ...overrides +}: MockInternalAccountOverrides): InternalAccount { + const normalizedOptions = normalizeOptions(options); + + return { + ...MOCK_ACCOUNT_EOA, + ...overrides, + id, + address: address ?? `${id}-address`, + metadata: { + ...MOCK_ACCOUNT_EOA.metadata, + ...metadata, + keyring: { + ...MOCK_ACCOUNT_EOA.metadata.keyring, + ...metadata?.keyring, + }, + }, + options: normalizedOptions, + }; +} + +export function createMockInternalAccounts( + mockAccounts: MockInternalAccountOverrides[], +): Record { + return Object.fromEntries( + mockAccounts.map((mockAccount) => [ + mockAccount.id, + createMockInternalAccount(mockAccount), + ]), + ); +}