From a1297d70748375e7db21bd7ceee8f67c92ac4e57 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Fri, 31 May 2024 17:51:18 +0800 Subject: [PATCH] feat: add getSelectedMultichainAccount and listMultichainAccounts (#4330) ## Explanation This pull request adds two new methods `getSelectedMultichainAccount`, `listMultichainAccounts` and `selectedEvmAccountChange` event. The optional arguments are to make the changes backwards compatible when used with evm specific controllers. ## References Related to: - [381](https://github.com/MetaMask/accounts-planning/issues/381) - [419](https://github.com/MetaMask/accounts-planning/issues/419) ## Changelog ### `@metamask/accounts-controller` - ****: Adds two new methods `getSelectedMultichainAccount`, `listMultichainAccounts`, and `selectedEvmAccountChange` event ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --------- Co-authored-by: Charly Chevalier --- packages/accounts-controller/jest.config.js | 2 + .../src/AccountsController.test.ts | 613 ++++++++++++++---- .../src/AccountsController.ts | 154 ++++- packages/accounts-controller/src/index.ts | 2 + .../src/tests/mocks.test.ts | 77 +++ .../accounts-controller/src/tests/mocks.ts | 79 +++ 6 files changed, 798 insertions(+), 129 deletions(-) create mode 100644 packages/accounts-controller/src/tests/mocks.test.ts create mode 100644 packages/accounts-controller/src/tests/mocks.ts diff --git a/packages/accounts-controller/jest.config.js b/packages/accounts-controller/jest.config.js index ca08413339..d6e04ca78a 100644 --- a/packages/accounts-controller/jest.config.js +++ b/packages/accounts-controller/jest.config.js @@ -14,6 +14,8 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + coveragePathIgnorePatterns: ['./src/tests'], + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { diff --git a/packages/accounts-controller/src/AccountsController.test.ts b/packages/accounts-controller/src/AccountsController.test.ts index 270274973e..385bf21187 100644 --- a/packages/accounts-controller/src/AccountsController.test.ts +++ b/packages/accounts-controller/src/AccountsController.test.ts @@ -1,9 +1,19 @@ import { ControllerMessenger } from '@metamask/base-controller'; -import type { InternalAccount } from '@metamask/keyring-api'; -import { EthAccountType, EthMethod } from '@metamask/keyring-api'; +import type { + InternalAccount, + InternalAccountType, +} from '@metamask/keyring-api'; +import { + BtcAccountType, + BtcMethod, + EthAccountType, + EthErc4337Method, + EthMethod, +} from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { SnapControllerState } from '@metamask/snaps-controllers'; import { SnapStatus } from '@metamask/snaps-utils'; +import type { CaipChainId } from '@metamask/utils'; import * as uuid from 'uuid'; import type { V4Options } from 'uuid'; @@ -15,6 +25,7 @@ import type { AllowedEvents, } from './AccountsController'; import { AccountsController } from './AccountsController'; +import { createMockInternalAccount } from './tests/mocks'; import { getUUIDOptionsFromAddressOfNormalAccount, keyringTypeToName, @@ -145,6 +156,9 @@ class MockNormalAccountUUID { * @param props.keyringType - The type of the keyring associated with the account. * @param props.snapId - The id of the snap. * @param props.snapEnabled - The status of the snap + * @param props.type - Account Type to create + * @param props.importTime - The import time of the account. + * @param props.lastSelected - The last selected time of the account. * @returns The `InternalAccount` object created from the normal account properties. */ function createExpectedInternalAccount({ @@ -154,6 +168,9 @@ function createExpectedInternalAccount({ keyringType, snapId, snapEnabled = true, + type = EthAccountType.Eoa, + importTime, + lastSelected, }: { id: string; name: string; @@ -161,20 +178,32 @@ function createExpectedInternalAccount({ keyringType: string; snapId?: string; snapEnabled?: boolean; + type?: InternalAccountType; + importTime?: number; + lastSelected?: number; }): InternalAccount { - const account: InternalAccount = { + const accountTypeToMethods = { + [`${EthAccountType.Eoa}`]: [...Object.values(EthMethod)], + [`${EthAccountType.Erc4337}`]: [...Object.values(EthErc4337Method)], + [`${BtcAccountType.P2wpkh}`]: [...Object.values(BtcMethod)], + }; + + const methods = + accountTypeToMethods[type as unknown as keyof typeof accountTypeToMethods]; + + const account = { id, address, options: {}, - methods: [...EOA_METHODS], - type: EthAccountType.Eoa, + methods, + type, metadata: { name, keyring: { type: keyringType }, - importTime: expect.any(Number), - lastSelected: expect.any(Number), + importTime: importTime || expect.any(Number), + lastSelected: lastSelected || expect.any(Number), }, - }; + } as InternalAccount; if (snapId) { account.metadata.snap = { @@ -259,7 +288,13 @@ function setupAccountsController({ AccountsControllerActions | AllowedActions, AccountsControllerEvents | AllowedEvents >; -}): AccountsController { +}): { + accountsController: AccountsController; + messenger: ControllerMessenger< + AccountsControllerActions | AllowedActions, + AccountsControllerEvents | AllowedEvents + >; +} { const accountsControllerMessenger = buildAccountsControllerMessenger(messenger); @@ -267,7 +302,7 @@ function setupAccountsController({ messenger: accountsControllerMessenger, state: { ...defaultState, ...initialState }, }); - return accountsController; + return { accountsController, messenger }; } describe('AccountsController', () => { @@ -276,7 +311,7 @@ describe('AccountsController', () => { }); describe('onSnapStateChange', () => { - it('should be used enable an account if the snap is enabled and not blocked', async () => { + it('be used enable an account if the Snap is enabled and not blocked', async () => { const messenger = buildMessenger(); const mockSnapAccount = createExpectedInternalAccount({ id: 'mock-id', @@ -298,7 +333,7 @@ describe('AccountsController', () => { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any as SnapControllerState; - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -319,7 +354,7 @@ describe('AccountsController', () => { expect(updatedAccount.metadata.snap?.enabled).toBe(true); }); - it('should be used disable an account if the snap is disabled', async () => { + it('be used disable an account if the Snap is disabled', async () => { const messenger = buildMessenger(); const mockSnapAccount = createExpectedInternalAccount({ id: 'mock-id', @@ -340,7 +375,7 @@ describe('AccountsController', () => { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any as SnapControllerState; - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -361,7 +396,7 @@ describe('AccountsController', () => { expect(updatedAccount.metadata.snap?.enabled).toBe(false); }); - it('should be used disable an account if the snap is blocked', async () => { + it('be used disable an account if the Snap is blocked', async () => { const messenger = buildMessenger(); const mockSnapAccount = createExpectedInternalAccount({ id: 'mock-id', @@ -382,7 +417,7 @@ describe('AccountsController', () => { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any as SnapControllerState; - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -408,9 +443,9 @@ describe('AccountsController', () => { afterEach(() => { jest.clearAllMocks(); }); - it('should not update state when only keyring is unlocked without any keyrings', async () => { + it('not update state when only keyring is unlocked without any keyrings', async () => { const messenger = buildMessenger(); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: {}, @@ -431,7 +466,7 @@ describe('AccountsController', () => { expect(accounts).toStrictEqual([]); }); - it('should only update if the keyring is unlocked and when there are keyrings', async () => { + it('only update if the keyring is unlocked and when there are keyrings', async () => { const messenger = buildMessenger(); const mockNewKeyringState = { @@ -443,7 +478,7 @@ describe('AccountsController', () => { }, ], }; - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: {}, @@ -465,7 +500,7 @@ describe('AccountsController', () => { }); describe('adding accounts', () => { - it('should add new accounts', async () => { + it('add new accounts', async () => { const messenger = buildMessenger(); mockUUID .mockReturnValueOnce('mock-id') // call to check if its a new account @@ -481,7 +516,7 @@ describe('AccountsController', () => { }, ], }; - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -508,7 +543,7 @@ describe('AccountsController', () => { ]); }); - it('should add snap accounts', async () => { + it('add Snap accounts', async () => { mockUUID.mockReturnValueOnce('mock-id'); // call to check if its a new account const messenger = buildMessenger(); @@ -539,7 +574,7 @@ describe('AccountsController', () => { ], }; - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -575,7 +610,7 @@ describe('AccountsController', () => { ]); }); - it('should handle the event when a snap deleted the account before the it was added', async () => { + it('handle the event when a Snap deleted the account before the it was added', async () => { mockUUID.mockReturnValueOnce('mock-id'); // call to check if its a new account const messenger = buildMessenger(); messenger.registerActionHandler( @@ -605,7 +640,7 @@ describe('AccountsController', () => { ], }; - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -632,7 +667,7 @@ describe('AccountsController', () => { ]); }); - it('should increment the default account number when adding an account', async () => { + it('increment the default account number when adding an account', async () => { const messenger = buildMessenger(); mockUUID .mockReturnValueOnce('mock-id') // call to check if its a new account @@ -653,7 +688,7 @@ describe('AccountsController', () => { }, ], }; - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -688,7 +723,7 @@ describe('AccountsController', () => { ]); }); - it('should use the next number after the total number of accounts of a keyring when adding an account, if the index is lower', async () => { + it('use the next number after the total number of accounts of a keyring when adding an account, if the index is lower', async () => { const messenger = buildMessenger(); mockUUID .mockReturnValueOnce('mock-id') // call to check if its a new account @@ -701,6 +736,8 @@ describe('AccountsController', () => { name: 'Custom Name', address: mockAccount2.address, keyringType: KeyringTypes.hd, + importTime: 1955565967656, + lastSelected: 1955565967656, }); const mockNewKeyringState = { @@ -716,7 +753,7 @@ describe('AccountsController', () => { }, ], }; - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -749,7 +786,7 @@ describe('AccountsController', () => { ]); }); - it('should handle when the account to set as selectedAccount is undefined', async () => { + it('handle when the account to set as selectedAccount is undefined', async () => { mockUUID.mockReturnValueOnce('mock-id'); // call to check if its a new account const messenger = buildMessenger(); @@ -777,7 +814,7 @@ describe('AccountsController', () => { ], }; - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: {}, @@ -800,7 +837,7 @@ describe('AccountsController', () => { }); describe('deleting account', () => { - it('should delete accounts if its gone from the keyring state', async () => { + it('delete accounts if its gone from the keyring state', async () => { const messenger = buildMessenger(); mockUUID.mockReturnValueOnce('mock-id2'); @@ -813,7 +850,7 @@ describe('AccountsController', () => { }, ], }; - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -840,7 +877,7 @@ describe('AccountsController', () => { ); }); - it('should delete accounts and set the most recent lastSelected account', async () => { + it('delete accounts and set the most recent lastSelected account', async () => { const messenger = buildMessenger(); mockUUID .mockReturnValueOnce('mock-id') @@ -857,7 +894,7 @@ describe('AccountsController', () => { }, ], }; - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -896,7 +933,7 @@ describe('AccountsController', () => { ); }); - it('should delete accounts and set the most recent lastSelected account when there are accounts that have never been selected', async () => { + it('delete accounts and set the most recent lastSelected account when there are accounts that have never been selected', async () => { const messenger = buildMessenger(); mockUUID .mockReturnValueOnce('mock-id') @@ -920,7 +957,7 @@ describe('AccountsController', () => { }, ], }; - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -959,7 +996,7 @@ describe('AccountsController', () => { ); }); - it('should delete the account and select the account with the most recent lastSelected', async () => { + it('delete the account and select the account with the most recent lastSelected', async () => { const messenger = buildMessenger(); mockUUID.mockReturnValueOnce('mock-id').mockReturnValueOnce('mock-id2'); @@ -991,7 +1028,7 @@ describe('AccountsController', () => { }, ], }; - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -1023,16 +1060,16 @@ describe('AccountsController', () => { const accounts = accountsController.listAccounts(); expect(accounts).toStrictEqual([ - setLastSelectedAsAny(mockAccountWithoutLastSelected), + mockAccountWithoutLastSelected, mockAccount2WithoutLastSelected, ]); expect(accountsController.getSelectedAccount()).toStrictEqual( - setLastSelectedAsAny(mockAccountWithoutLastSelected), + mockAccountWithoutLastSelected, ); }); }); - it('should handle keyring reinitialization', async () => { + it('handle keyring reinitialization', async () => { const messenger = buildMessenger(); const mockInitialAccount = createExpectedInternalAccount({ id: 'mock-id', @@ -1059,7 +1096,7 @@ describe('AccountsController', () => { }, ], }; - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -1084,6 +1121,90 @@ describe('AccountsController', () => { expect(selectedAccount).toStrictEqual(expectedAccount); expect(accounts).toStrictEqual([expectedAccount]); }); + + it.each([ + { + lastSelectedForAccount1: 1111, + lastSelectedForAccount2: 9999, + expectedSelectedId: 'mock-id2', + }, + { + lastSelectedForAccount1: undefined, + lastSelectedForAccount2: 9999, + expectedSelectedId: 'mock-id2', + }, + { + lastSelectedForAccount1: 1111, + lastSelectedForAccount2: undefined, + expectedSelectedId: 'mock-id', + }, + { + lastSelectedForAccount1: 1111, + lastSelectedForAccount2: 0, + expectedSelectedId: 'mock-id', + }, + ])( + 'handle keyring reinitialization with multiple accounts. Account 1 lastSelected $lastSelectedForAccount1, Account 2 lastSelected $lastSelectedForAccount2. Expected selected account: $expectedSelectedId', + async ({ + lastSelectedForAccount1, + lastSelectedForAccount2, + expectedSelectedId, + }) => { + const messenger = buildMessenger(); + const mockExistingAccount1 = createExpectedInternalAccount({ + id: 'mock-id', + name: 'Account 1', + address: '0x123', + keyringType: KeyringTypes.hd, + }); + mockExistingAccount1.metadata.lastSelected = lastSelectedForAccount1; + const mockExistingAccount2 = createExpectedInternalAccount({ + id: 'mock-id2', + name: 'Account 2', + address: '0x456', + keyringType: KeyringTypes.hd, + }); + mockExistingAccount2.metadata.lastSelected = lastSelectedForAccount2; + + mockUUID + .mockReturnValueOnce('mock-id') // call to check if its a new account + .mockReturnValueOnce('mock-id2'); // call to check if its a new account + + const { accountsController } = setupAccountsController({ + initialState: { + internalAccounts: { + accounts: { + [mockExistingAccount1.id]: mockExistingAccount1, + [mockExistingAccount2.id]: mockExistingAccount2, + }, + selectedAccount: 'unknown', + }, + }, + messenger, + }); + const mockNewKeyringState = { + isUnlocked: true, + keyrings: [ + { + type: KeyringTypes.hd, + accounts: [ + mockExistingAccount1.address, + mockExistingAccount2.address, + ], + }, + ], + }; + messenger.publish( + 'KeyringController:stateChange', + mockNewKeyringState, + [], + ); + + const selectedAccount = accountsController.getSelectedAccount(); + + expect(selectedAccount.id).toStrictEqual(expectedSelectedId); + }, + ); }); describe('updateAccounts', () => { @@ -1134,7 +1255,7 @@ describe('AccountsController', () => { jest.clearAllMocks(); }); - it('should update accounts with normal accounts', async () => { + it('update accounts with normal accounts', async () => { mockUUID.mockReturnValueOnce('mock-id').mockReturnValueOnce('mock-id2'); const messenger = buildMessenger(); messenger.registerActionHandler( @@ -1157,7 +1278,7 @@ describe('AccountsController', () => { ]), ); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: {}, @@ -1186,7 +1307,7 @@ describe('AccountsController', () => { expect(accountsController.listAccounts()).toStrictEqual(expectedAccounts); }); - it('should update accounts with snap accounts when snap keyring is defined and has accounts', async () => { + it('update accounts with Snap accounts when snap keyring is defined and has accounts', async () => { const messenger = buildMessenger(); messenger.registerActionHandler( 'KeyringController:getAccounts', @@ -1203,7 +1324,7 @@ describe('AccountsController', () => { ]), ); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: {}, @@ -1242,7 +1363,7 @@ describe('AccountsController', () => { ).toStrictEqual(expectedAccounts); }); - it('should return an empty array if the snap keyring is not defined', async () => { + it('return an empty array if the Snap keyring is not defined', async () => { const messenger = buildMessenger(); messenger.registerActionHandler( 'KeyringController:getAccounts', @@ -1254,7 +1375,7 @@ describe('AccountsController', () => { mockGetKeyringByType.mockReturnValueOnce([undefined]), ); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: {}, @@ -1271,7 +1392,7 @@ describe('AccountsController', () => { expect(accountsController.listAccounts()).toStrictEqual(expectedAccounts); }); - it('should set the account with the correct index', async () => { + it('set the account with the correct index', async () => { mockUUID.mockReturnValueOnce('mock-id').mockReturnValueOnce('mock-id2'); const messenger = buildMessenger(); messenger.registerActionHandler( @@ -1294,7 +1415,7 @@ describe('AccountsController', () => { ]), ); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -1320,7 +1441,7 @@ describe('AccountsController', () => { expect(accountsController.listAccounts()).toStrictEqual(expectedAccounts); }); - it('should filter snap accounts from normalAccounts', async () => { + it('filter Snap accounts from normalAccounts', async () => { mockUUID.mockReturnValueOnce('mock-id'); const messenger = buildMessenger(); messenger.registerActionHandler( @@ -1345,7 +1466,7 @@ describe('AccountsController', () => { .mockResolvedValueOnce({ type: KeyringTypes.snap }), ); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: {}, @@ -1375,7 +1496,7 @@ describe('AccountsController', () => { expect(accountsController.listAccounts()).toStrictEqual(expectedAccounts); }); - it('should filter snap accounts from normalAccounts even if the snap account is listed before normal accounts', async () => { + it('filter Snap accounts from normalAccounts even if the snap account is listed before normal accounts', async () => { mockUUID.mockReturnValue('mock-id'); const messenger = buildMessenger(); messenger.registerActionHandler( @@ -1400,7 +1521,7 @@ describe('AccountsController', () => { .mockResolvedValueOnce({ type: KeyringTypes.hd }), ); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: {}, @@ -1462,7 +1583,7 @@ describe('AccountsController', () => { ]), ); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: {}, @@ -1488,7 +1609,7 @@ describe('AccountsController', () => { ).toStrictEqual(expectedAccounts); }); - it('should throw an error if the keyring type is unknown', async () => { + it('throw an error if the keyring type is unknown', async () => { mockUUID.mockReturnValue('mock-id'); const messenger = buildMessenger(); @@ -1511,7 +1632,7 @@ describe('AccountsController', () => { ]), ); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: {}, @@ -1528,8 +1649,8 @@ describe('AccountsController', () => { }); describe('loadBackup', () => { - it('should load a backup', async () => { - const accountsController = setupAccountsController({ + it('load a backup', async () => { + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: {}, @@ -1557,8 +1678,8 @@ describe('AccountsController', () => { }); }); - it('should not load backup if the data is undefined', () => { - const accountsController = setupAccountsController({ + it('not load backup if the data is undefined', () => { + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount }, @@ -1582,8 +1703,8 @@ describe('AccountsController', () => { }); describe('getAccount', () => { - it('should return an account by ID', () => { - const accountsController = setupAccountsController({ + it('return an account by ID', () => { + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount }, @@ -1598,8 +1719,8 @@ describe('AccountsController', () => { setLastSelectedAsAny(mockAccount as InternalAccount), ); }); - it('should return undefined for an unknown account ID', () => { - const accountsController = setupAccountsController({ + it('return undefined for an unknown account ID', () => { + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount }, @@ -1614,32 +1735,260 @@ describe('AccountsController', () => { }); }); + describe('getSelectedAccount', () => { + const mockNonEvmAccount = createExpectedInternalAccount({ + id: 'mock-non-evm', + name: 'non-evm', + address: 'bc1qzqc2aqlw8nwa0a05ehjkk7dgt8308ac7kzw9a6', + keyringType: KeyringTypes.snap, + type: BtcAccountType.P2wpkh, + }); + + const mockOlderEvmAccount = createExpectedInternalAccount({ + id: 'mock-id-1', + name: 'mock account 1', + address: 'mock-address-1', + keyringType: KeyringTypes.hd, + lastSelected: 11111, + }); + const mockNewerEvmAccount = createExpectedInternalAccount({ + id: 'mock-id-2', + name: 'mock account 2', + address: 'mock-address-2', + keyringType: KeyringTypes.hd, + lastSelected: 22222, + }); + + it.each([ + { + lastSelectedAccount: mockNewerEvmAccount, + expected: mockNewerEvmAccount, + }, + { + lastSelectedAccount: mockOlderEvmAccount, + expected: mockOlderEvmAccount, + }, + { + lastSelectedAccount: mockNonEvmAccount, + expected: mockNewerEvmAccount, + }, + ])( + 'last selected account type is $lastSelectedAccount.type should return the selectedAccount with id $expected.id', + ({ lastSelectedAccount, expected }) => { + const { accountsController } = setupAccountsController({ + initialState: { + internalAccounts: { + accounts: { + [mockOlderEvmAccount.id]: mockOlderEvmAccount, + [mockNewerEvmAccount.id]: mockNewerEvmAccount, + [mockNonEvmAccount.id]: mockNonEvmAccount, + }, + selectedAccount: lastSelectedAccount.id, + }, + }, + }); + + expect(accountsController.getSelectedAccount()).toStrictEqual(expected); + }, + ); + + it("throw error if there aren't any EVM accounts", () => { + const { accountsController } = setupAccountsController({ + initialState: { + internalAccounts: { + accounts: { + [mockNonEvmAccount.id]: mockNonEvmAccount, + }, + selectedAccount: mockNonEvmAccount.id, + }, + }, + }); + + expect(() => accountsController.getSelectedAccount()).toThrow( + 'No EVM accounts', + ); + }); + }); + + describe('getSelectedMultichainAccount', () => { + const mockNonEvmAccount = createExpectedInternalAccount({ + id: 'mock-non-evm', + name: 'non-evm', + address: 'bc1qzqc2aqlw8nwa0a05ehjkk7dgt8308ac7kzw9a6', + keyringType: KeyringTypes.snap, + type: BtcAccountType.P2wpkh, + }); + + const mockOlderEvmAccount = createExpectedInternalAccount({ + id: 'mock-id-1', + name: 'mock account 1', + address: 'mock-address-1', + keyringType: KeyringTypes.hd, + lastSelected: 11111, + }); + const mockNewerEvmAccount = createExpectedInternalAccount({ + id: 'mock-id-2', + name: 'mock account 2', + address: 'mock-address-2', + keyringType: KeyringTypes.hd, + lastSelected: 22222, + }); + + it.each([ + { + chainId: undefined, + selectedAccount: mockNewerEvmAccount, + expected: mockNewerEvmAccount, + }, + { + chainId: undefined, + selectedAccount: mockNonEvmAccount, + expected: mockNonEvmAccount, + }, + { + chainId: 'eip155:1', + selectedAccount: mockNonEvmAccount, + expected: mockNewerEvmAccount, + }, + { + chainId: 'bip122:000000000019d6689c085ae165831e93', + selectedAccount: mockNonEvmAccount, + expected: mockNonEvmAccount, + }, + ])( + "chainId $chainId with selectedAccount '$selectedAccount.id' should return $expected.id", + ({ chainId, selectedAccount, expected }) => { + const { accountsController } = setupAccountsController({ + initialState: { + internalAccounts: { + accounts: { + [mockOlderEvmAccount.id]: mockOlderEvmAccount, + [mockNewerEvmAccount.id]: mockNewerEvmAccount, + [mockNonEvmAccount.id]: mockNonEvmAccount, + }, + selectedAccount: selectedAccount.id, + }, + }, + }); + + expect( + accountsController.getSelectedMultichainAccount( + chainId as CaipChainId, + ), + ).toStrictEqual(expected); + }, + ); + + // Testing error cases + it.each([['eip155.'], ['bip122'], ['bip122:...']])( + 'invalid chainId %s will throw', + (chainId) => { + const { accountsController } = setupAccountsController({ + initialState: { + internalAccounts: { + accounts: { + [mockOlderEvmAccount.id]: mockOlderEvmAccount, + [mockNewerEvmAccount.id]: mockNewerEvmAccount, + [mockNonEvmAccount.id]: mockNonEvmAccount, + }, + selectedAccount: mockNonEvmAccount.id, + }, + }, + }); + + expect(() => + accountsController.getSelectedMultichainAccount( + chainId as CaipChainId, + ), + ).toThrow(`Invalid CAIP-2 chain ID: ${chainId}`); + }, + ); + }); + describe('listAccounts', () => { - it('should return a list of accounts', () => { - const accountsController = setupAccountsController({ + it('returns a list of evm accounts', () => { + const mockNonEvmAccount = createMockInternalAccount({ + id: 'mock-id-non-evm', + address: 'mock-non-evm-address', + type: BtcAccountType.P2wpkh, + keyringType: KeyringTypes.snap, + }); + + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount, [mockAccount2.id]: mockAccount2, + [mockNonEvmAccount.id]: mockNonEvmAccount, }, selectedAccount: mockAccount.id, }, }, }); - const result = accountsController.listAccounts(); - - expect(result).toStrictEqual([ - setLastSelectedAsAny(mockAccount as InternalAccount), - setLastSelectedAsAny(mockAccount2 as InternalAccount), + expect(accountsController.listAccounts()).toStrictEqual([ + mockAccount, + mockAccount2, ]); }); }); + describe('listMultichainAccounts', () => { + const mockNonEvmAccount = createMockInternalAccount({ + id: 'mock-id-non-evm', + address: 'mock-non-evm-address', + type: BtcAccountType.P2wpkh, + keyringType: KeyringTypes.snap, + }); + + it.each([ + [undefined, [mockAccount, mockAccount2, mockNonEvmAccount]], + ['eip155:1', [mockAccount, mockAccount2]], + ['bip122:000000000019d6689c085ae165831e93', [mockNonEvmAccount]], + ])(`%s should return %s`, (chainId, expected) => { + const { accountsController } = setupAccountsController({ + initialState: { + internalAccounts: { + accounts: { + [mockAccount.id]: mockAccount, + [mockAccount2.id]: mockAccount2, + [mockNonEvmAccount.id]: mockNonEvmAccount, + }, + selectedAccount: mockAccount.id, + }, + }, + }); + expect( + accountsController.listMultichainAccounts(chainId as CaipChainId), + ).toStrictEqual(expected); + }); + + it('throw if invalid CAIP-2 was passed', () => { + const { accountsController } = setupAccountsController({ + initialState: { + internalAccounts: { + accounts: { + [mockAccount.id]: mockAccount, + [mockAccount2.id]: mockAccount2, + }, + selectedAccount: mockAccount.id, + }, + }, + }); + + const invalidCaip2 = 'ethereum'; + + expect(() => + // @ts-expect-error testing invalid caip2 + accountsController.listMultichainAccounts(invalidCaip2), + ).toThrow(`Invalid CAIP-2 chain ID: ${invalidCaip2}`); + }); + }); + describe('getAccountExpect', () => { - it('should return an account by ID', () => { - const accountsController = setupAccountsController({ + it('return an account by ID', () => { + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount }, @@ -1654,9 +2003,9 @@ describe('AccountsController', () => { ); }); - it('should throw an error for an unknown account ID', () => { + it('throw an error for an unknown account ID', () => { const accountId = 'unknown id'; - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount }, @@ -1670,8 +2019,8 @@ describe('AccountsController', () => { ); }); - it('should handle the edge case of undefined accountId during onboarding', async () => { - const accountsController = setupAccountsController({ + it('handle the edge case of undefined accountId during onboarding', async () => { + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount }, @@ -1698,49 +2047,72 @@ describe('AccountsController', () => { }); }); - describe('getSelectedAccount', () => { - it('should return the selected account', () => { - const accountsController = setupAccountsController({ + describe('setSelectedAccount', () => { + it('set the selected account', () => { + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { - accounts: { [mockAccount.id]: mockAccount }, + accounts: { + [mockAccount.id]: mockAccount, + [mockAccount2.id]: mockAccount2, + }, selectedAccount: mockAccount.id, }, }, }); - const result = accountsController.getAccountExpect(mockAccount.id); - expect(result).toStrictEqual( - setLastSelectedAsAny(mockAccount as InternalAccount), - ); + accountsController.setSelectedAccount(mockAccount2.id); + + expect( + accountsController.state.internalAccounts.selectedAccount, + ).toStrictEqual(mockAccount2.id); }); - }); - describe('setSelectedAccount', () => { - it('should set the selected account', () => { - const accountsController = setupAccountsController({ + it('not emit setSelectedEvmAccountChange if the account is non-EVM', () => { + const mockNonEvmAccount = createExpectedInternalAccount({ + id: 'mock-non-evm', + name: 'non-evm', + address: 'bc1qzqc2aqlw8nwa0a05ehjkk7dgt8308ac7kzw9a6', + keyringType: KeyringTypes.snap, + type: BtcAccountType.P2wpkh, + }); + const { accountsController, messenger } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount, - [mockAccount2.id]: mockAccount2, + [mockNonEvmAccount.id]: mockNonEvmAccount, }, selectedAccount: mockAccount.id, }, }, }); - accountsController.setSelectedAccount(mockAccount2.id); + const messengerSpy = jest.spyOn(messenger, 'publish'); + + accountsController.setSelectedAccount(mockNonEvmAccount.id); expect( accountsController.state.internalAccounts.selectedAccount, - ).toStrictEqual(mockAccount2.id); + ).toStrictEqual(mockNonEvmAccount.id); + + expect(messengerSpy.mock.calls).toHaveLength(2); // state change and then selectedAccountChange + + expect(messengerSpy).not.toHaveBeenCalledWith( + 'AccountsController:selectedEvmAccountChange', + mockNonEvmAccount, + ); + + expect(messengerSpy).toHaveBeenCalledWith( + 'AccountsController:selectedAccountChange', + mockNonEvmAccount, + ); }); }); describe('setAccountName', () => { - it('should set the name of an existing account', () => { - const accountsController = setupAccountsController({ + it('set the name of an existing account', () => { + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount }, @@ -1755,8 +2127,8 @@ describe('AccountsController', () => { ).toBe('new name'); }); - it('should throw an error if the account name already exists', () => { - const accountsController = setupAccountsController({ + it('throw an error if the account name already exists', () => { + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -1812,7 +2184,7 @@ describe('AccountsController', () => { }; }; - it('should return the next account number', async () => { + it('return the next account number', async () => { const messenger = buildMessenger(); mockUUID .mockReturnValueOnce('mock-id') // call to check if its a new account @@ -1821,7 +2193,7 @@ describe('AccountsController', () => { .mockReturnValueOnce('mock-id2') // call to add account .mockReturnValueOnce('mock-id3'); // call to add account - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -1850,7 +2222,7 @@ describe('AccountsController', () => { ]); }); - it('should return the next account number even with an index gap', async () => { + it('return the next account number even with an index gap', async () => { const messenger = buildMessenger(); const mockAccountUUIDs = new MockNormalAccountUUID([ mockAccount, @@ -1860,7 +2232,7 @@ describe('AccountsController', () => { ]); mockUUID.mockImplementation(mockAccountUUIDs.mock.bind(mockAccountUUIDs)); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { @@ -1909,8 +2281,8 @@ describe('AccountsController', () => { }); describe('getAccountByAddress', () => { - it('should return an account by address', async () => { - const accountsController = setupAccountsController({ + it('return an account by address', async () => { + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount }, @@ -1927,7 +2299,7 @@ describe('AccountsController', () => { }); it("should return undefined if there isn't an account with the address", () => { - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount }, @@ -1951,13 +2323,12 @@ describe('AccountsController', () => { jest.spyOn(AccountsController.prototype, 'getAccountByAddress'); jest.spyOn(AccountsController.prototype, 'getSelectedAccount'); jest.spyOn(AccountsController.prototype, 'getAccount'); - jest.spyOn(AccountsController.prototype, 'getNextAvailableAccountName'); }); describe('setSelectedAccount', () => { - it('should set the selected account', async () => { + it('set the selected account', async () => { const messenger = buildMessenger(); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount }, @@ -1975,9 +2346,9 @@ describe('AccountsController', () => { }); describe('listAccounts', () => { - it('should retrieve a list of accounts', async () => { + it('retrieve a list of accounts', async () => { const messenger = buildMessenger(); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount }, @@ -1993,9 +2364,9 @@ describe('AccountsController', () => { }); describe('setAccountName', () => { - it('should set the account name', async () => { + it('set the account name', async () => { const messenger = buildMessenger(); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount }, @@ -2018,7 +2389,7 @@ describe('AccountsController', () => { }); describe('updateAccounts', () => { - it('should update accounts', async () => { + it('update accounts', async () => { const messenger = buildMessenger(); messenger.registerActionHandler( 'KeyringController:getAccounts', @@ -2033,7 +2404,7 @@ describe('AccountsController', () => { mockGetKeyringForAccount.mockResolvedValueOnce([]), ); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount }, @@ -2049,10 +2420,10 @@ describe('AccountsController', () => { }); describe('getAccountByAddress', () => { - it('should get account by address', async () => { + it('get account by address', async () => { const messenger = buildMessenger(); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount }, @@ -2074,10 +2445,10 @@ describe('AccountsController', () => { }); describe('getSelectedAccount', () => { - it('should get account by address', async () => { + it('get account by address', async () => { const messenger = buildMessenger(); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount }, @@ -2094,10 +2465,10 @@ describe('AccountsController', () => { }); describe('getAccount', () => { - it('should get account by id', async () => { + it('get account by id', async () => { const messenger = buildMessenger(); - const accountsController = setupAccountsController({ + const { accountsController } = setupAccountsController({ initialState: { internalAccounts: { accounts: { [mockAccount.id]: mockAccount }, diff --git a/packages/accounts-controller/src/AccountsController.ts b/packages/accounts-controller/src/AccountsController.ts index 8fec7bc333..820ab6447f 100644 --- a/packages/accounts-controller/src/AccountsController.ts +++ b/packages/accounts-controller/src/AccountsController.ts @@ -6,7 +6,11 @@ import type { import { BaseController } from '@metamask/base-controller'; import { SnapKeyring } from '@metamask/eth-snap-keyring'; import type { InternalAccount } from '@metamask/keyring-api'; -import { EthAccountType, EthMethod } from '@metamask/keyring-api'; +import { + EthAccountType, + EthMethod, + isEvmAccountType, +} from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { KeyringControllerState, @@ -21,7 +25,13 @@ import type { } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; import type { Snap } from '@metamask/snaps-utils'; -import type { Keyring, Json } from '@metamask/utils'; +import type { CaipChainId } from '@metamask/utils'; +import { + type Keyring, + type Json, + isCaipChainId, + parseCaipChainId, +} from '@metamask/utils'; import type { Draft } from 'immer'; import { @@ -70,6 +80,11 @@ export type AccountsControllerGetSelectedAccountAction = { handler: AccountsController['getSelectedAccount']; }; +export type AccountsControllerGetSelectedMultichainAccountAction = { + type: `${typeof controllerName}:getSelectedMultichainAccount`; + handler: AccountsController['getSelectedMultichainAccount']; +}; + export type AccountsControllerGetAccountByAddressAction = { type: `${typeof controllerName}:getAccountByAddress`; handler: AccountsController['getAccountByAddress']; @@ -99,7 +114,8 @@ export type AccountsControllerActions = | AccountsControllerGetAccountByAddressAction | AccountsControllerGetSelectedAccountAction | AccountsControllerGetNextAvailableAccountNameAction - | AccountsControllerGetAccountAction; + | AccountsControllerGetAccountAction + | AccountsControllerGetSelectedMultichainAccountAction; export type AccountsControllerChangeEvent = ControllerStateChangeEvent< typeof controllerName, @@ -111,11 +127,17 @@ export type AccountsControllerSelectedAccountChangeEvent = { payload: [InternalAccount]; }; +export type AccountsControllerSelectedEvmAccountChangeEvent = { + type: `${typeof controllerName}:selectedEvmAccountChange`; + payload: [InternalAccount]; +}; + export type AllowedEvents = SnapStateChange | KeyringControllerStateChangeEvent; export type AccountsControllerEvents = | AccountsControllerChangeEvent - | AccountsControllerSelectedAccountChangeEvent; + | AccountsControllerSelectedAccountChangeEvent + | AccountsControllerSelectedEvmAccountChangeEvent; export type AccountsControllerMessenger = RestrictedControllerMessenger< typeof controllerName, @@ -205,12 +227,34 @@ export class AccountsController extends BaseController< } /** - * Returns an array of all internal accounts. + * Returns an array of all evm internal accounts. * * @returns An array of InternalAccount objects. */ listAccounts(): InternalAccount[] { - return Object.values(this.state.internalAccounts.accounts); + const accounts = Object.values(this.state.internalAccounts.accounts); + return accounts.filter((account) => isEvmAccountType(account.type)); + } + + /** + * Returns an array of all internal accounts. + * + * @param chainId - The chain ID. + * @returns An array of InternalAccount objects. + */ + listMultichainAccounts(chainId?: CaipChainId): InternalAccount[] { + const accounts = Object.values(this.state.internalAccounts.accounts); + if (!chainId) { + return accounts; + } + + if (!isCaipChainId(chainId)) { + throw new Error(`Invalid CAIP-2 chain ID: ${String(chainId)}`); + } + + return accounts.filter((account) => + this.#isAccountCompatibleWithChain(account, chainId), + ); } /** @@ -248,12 +292,54 @@ export class AccountsController extends BaseController< } /** - * Returns the selected internal account. + * Returns the last selected evm account. * * @returns The selected internal account. */ getSelectedAccount(): InternalAccount { - return this.getAccountExpect(this.state.internalAccounts.selectedAccount); + const selectedAccount = this.getAccountExpect( + this.state.internalAccounts.selectedAccount, + ); + if (isEvmAccountType(selectedAccount.type)) { + return selectedAccount; + } + + const accounts = this.listAccounts(); + + if (!accounts.length) { + // ! Should never reach this. + throw new Error('No EVM accounts'); + } + + // This will never be undefined because we have already checked if accounts.length is > 0 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.#getLastSelectedAccount(accounts)!; + } + + /** + * __WARNING The return value may be undefined if there isn't an account for that chain id.__ + * + * Retrieves the last selected account by chain ID. + * + * @param chainId - The chain ID to filter the accounts. + * @returns The last selected account compatible with the specified chain ID or undefined. + */ + getSelectedMultichainAccount( + chainId?: CaipChainId, + ): InternalAccount | undefined { + if (!chainId) { + return this.getAccountExpect(this.state.internalAccounts.selectedAccount); + } + + if (!isCaipChainId(chainId)) { + throw new Error(`Invalid CAIP-2 chain ID: ${chainId as string}`); + } + + const accounts = Object.values(this.state.internalAccounts.accounts).filter( + (account) => this.#isAccountCompatibleWithChain(account, chainId), + ); + + return this.#getLastSelectedAccount(accounts); } /** @@ -282,6 +368,13 @@ export class AccountsController extends BaseController< currentState.internalAccounts.selectedAccount = account.id; }); + if (isEvmAccountType(account.type)) { + this.messagingSystem.publish( + 'AccountsController:selectedEvmAccountChange', + account, + ); + } + this.messagingSystem.publish( 'AccountsController:selectedAccountChange', account, @@ -707,6 +800,30 @@ export class AccountsController extends BaseController< }); } + /** + * Returns the last selected account from the given array of accounts. + * + * @param accounts - An array of InternalAccount objects. + * @returns The InternalAccount object that was last selected, or undefined if the array is empty. + */ + #getLastSelectedAccount( + accounts: InternalAccount[], + ): InternalAccount | undefined { + return accounts.reduce((prevAccount, currentAccount) => { + if ( + // When the account is added, lastSelected will be set + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + currentAccount.metadata.lastSelected! > + // When the account is added, lastSelected will be set + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + prevAccount.metadata.lastSelected! + ) { + return currentAccount; + } + return prevAccount; + }, accounts[0]); + } + /** * Returns the next account number for a given keyring type. * @param keyringType - The type of keyring. @@ -745,6 +862,22 @@ export class AccountsController extends BaseController< return `${keyringName} ${index}`; } + /** + * Checks if an account is compatible with a given chain namespace. + * @private + * @param account - The account to check compatibility for. + * @param chainId - The CAIP2 to check compatibility with. + * @returns Returns true if the account is compatible with the chain namespace, otherwise false. + */ + #isAccountCompatibleWithChain( + account: InternalAccount, + chainId: CaipChainId, + ): boolean { + // TODO: Change this logic to not use account's type + // Because we currently only use type, we can only use namespace for now. + return account.type.startsWith(parseCaipChainId(chainId).namespace); + } + /** * Handles the addition of a new account to the controller. * If the account is not a Snap Keyring account, generates an internal account for it and adds it to the controller. @@ -853,6 +986,11 @@ export class AccountsController extends BaseController< this.getSelectedAccount.bind(this), ); + this.messagingSystem.registerActionHandler( + `${controllerName}:getSelectedMultichainAccount`, + this.getSelectedMultichainAccount.bind(this), + ); + this.messagingSystem.registerActionHandler( `${controllerName}:getAccountByAddress`, this.getAccountByAddress.bind(this), diff --git a/packages/accounts-controller/src/index.ts b/packages/accounts-controller/src/index.ts index 274efa5d5b..29505118b6 100644 --- a/packages/accounts-controller/src/index.ts +++ b/packages/accounts-controller/src/index.ts @@ -11,8 +11,10 @@ export type { AccountsControllerActions, AccountsControllerChangeEvent, AccountsControllerSelectedAccountChangeEvent, + AccountsControllerSelectedEvmAccountChangeEvent, AccountsControllerEvents, AccountsControllerMessenger, } from './AccountsController'; export { AccountsController } from './AccountsController'; export { keyringTypeToName, getUUIDFromAddressOfNormalAccount } from './utils'; +export { createMockInternalAccount } from './tests/mocks'; diff --git a/packages/accounts-controller/src/tests/mocks.test.ts b/packages/accounts-controller/src/tests/mocks.test.ts new file mode 100644 index 0000000000..972b3356a5 --- /dev/null +++ b/packages/accounts-controller/src/tests/mocks.test.ts @@ -0,0 +1,77 @@ +import { BtcAccountType, EthAccountType } from '@metamask/keyring-api'; + +import { createMockInternalAccount } from './mocks'; + +describe('createMockInternalAccount', () => { + it('create a mock internal account', () => { + const account = createMockInternalAccount(); + expect(account).toStrictEqual({ + id: expect.any(String), + address: expect.any(String), + type: expect.any(String), + options: expect.any(Object), + methods: expect.any(Array), + metadata: { + name: expect.any(String), + keyring: { type: expect.any(String) }, + importTime: expect.any(Number), + lastSelected: expect.any(Number), + snap: undefined, + }, + }); + }); + + it('create a mock internal account with custom values', () => { + const customSnap = { + id: '1', + enabled: true, + name: 'Snap 1', + }; + const account = createMockInternalAccount({ + id: '1', + address: '0x123', + type: EthAccountType.Erc4337, + name: 'Custom Account', + snap: customSnap, + }); + expect(account).toStrictEqual({ + id: '1', + address: '0x123', + type: EthAccountType.Erc4337, + options: expect.any(Object), + methods: expect.any(Array), + metadata: { + name: 'Custom Account', + keyring: { type: expect.any(String) }, + importTime: expect.any(Number), + lastSelected: expect.any(Number), + snap: customSnap, + }, + }); + }); + + it('create a non-EVM account', () => { + const account = createMockInternalAccount({ type: BtcAccountType.P2wpkh }); + expect(account).toStrictEqual({ + id: expect.any(String), + address: expect.any(String), + type: BtcAccountType.P2wpkh, + options: expect.any(Object), + methods: expect.any(Array), + metadata: { + name: expect.any(String), + keyring: { type: expect.any(String) }, + importTime: expect.any(Number), + lastSelected: expect.any(Number), + snap: undefined, + }, + }); + }); + + it('will throw if an unknown account type was passed', () => { + // @ts-expect-error testing unknown account type + expect(() => createMockInternalAccount({ type: 'unknown' })).toThrow( + 'Unknown account type: unknown', + ); + }); +}); diff --git a/packages/accounts-controller/src/tests/mocks.ts b/packages/accounts-controller/src/tests/mocks.ts new file mode 100644 index 0000000000..59a9892a1a --- /dev/null +++ b/packages/accounts-controller/src/tests/mocks.ts @@ -0,0 +1,79 @@ +import type { + InternalAccount, + InternalAccountType, +} from '@metamask/keyring-api'; +import { + BtcAccountType, + BtcMethod, + EthAccountType, + EthErc4337Method, + EthMethod, +} from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { v4 } from 'uuid'; + +export const createMockInternalAccount = ({ + id = v4(), + address = '0x2990079bcdee240329a520d2444386fc119da21a', + type = EthAccountType.Eoa, + name = 'Account 1', + keyringType = KeyringTypes.hd, + snap, + importTime = Date.now(), + lastSelected = Date.now(), +}: { + id?: string; + address?: string; + type?: InternalAccountType; + name?: string; + keyringType?: KeyringTypes; + snap?: { + id: string; + enabled: boolean; + name: string; + }; + importTime?: number; + lastSelected?: number; +} = {}): InternalAccount => { + let methods; + + switch (type) { + case EthAccountType.Eoa: + methods = [ + EthMethod.PersonalSign, + EthMethod.Sign, + EthMethod.SignTransaction, + EthMethod.SignTypedDataV1, + EthMethod.SignTypedDataV3, + EthMethod.SignTypedDataV4, + ]; + break; + case EthAccountType.Erc4337: + methods = [ + EthErc4337Method.PatchUserOperation, + EthErc4337Method.PrepareUserOperation, + EthErc4337Method.SignUserOperation, + ]; + break; + case BtcAccountType.P2wpkh: + methods = [BtcMethod.SendMany]; + break; + default: + throw new Error(`Unknown account type: ${type as string}`); + } + + return { + id, + address, + options: {}, + methods, + type, + metadata: { + name, + keyring: { type: keyringType }, + importTime, + lastSelected, + snap, + }, + } as InternalAccount; +};