diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 77cb0f75b1..7d2c9b3673 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `AccountsController` and `ConnectivityController` as default initialized controllers ([#8924](https://github.com/MetaMask/core/pull/8924)) + ## [1.0.1] ### Changed diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index a3a8dca781..be5a999659 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -1,10 +1,14 @@ +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; import { Messenger } from '@metamask/messenger'; import { Json } from '@metamask/utils'; import { webcrypto } from 'crypto'; import MockEncryptor from '../../keyring-controller/tests/mocks/mockEncryptor'; import * as initializationModule from './initialization/initialization'; -import { importSecretRecoveryPhrase } from './utilities'; +import { + createSecretRecoveryPhrase, + importSecretRecoveryPhrase, +} from './utilities'; import { Wallet } from './Wallet'; const TEST_SRP = 'test test test test test test test test test test test ball'; @@ -176,6 +180,34 @@ describe('Wallet', () => { expect(spy).toHaveBeenCalledTimes(1); }); + describe('AccountsController', () => { + it('tracks accounts created via KeyringController', async () => { + const wallet = new Wallet({}); + await createSecretRecoveryPhrase(wallet, TEST_PASSWORD); + + const keyringAccounts = await wallet.messenger.call( + 'KeyringController:getAccounts', + ); + const trackedAddresses = Object.values( + wallet.state.AccountsController.internalAccounts.accounts, + ).map((account) => account.address); + + expect(trackedAddresses).toStrictEqual(keyringAccounts); + }); + }); + + describe('ConnectivityController', () => { + it('reports online connectivity status', async () => { + const wallet = new Wallet({}); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(wallet.state.ConnectivityController.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Online, + ); + }); + }); + describe('KeyringController', () => { it('can unlock and populate accounts', async () => { const wallet = await setupWallet(); diff --git a/packages/wallet/src/initialization/instances/accounts-controller.ts b/packages/wallet/src/initialization/instances/accounts-controller.ts new file mode 100644 index 0000000000..c4903d2d7f --- /dev/null +++ b/packages/wallet/src/initialization/instances/accounts-controller.ts @@ -0,0 +1,64 @@ +import { + AccountsController, + AccountsControllerMessenger, + AccountsControllerState, +} from '@metamask/accounts-controller'; +import { + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import type { DefaultActions, DefaultEvents, RootMessenger } from '../defaults'; +import type { InitializationConfiguration } from '../types'; + +// TODO: AccountsController is deprecated in favour of AccountTreeController +// and MultichainAccountService. Migrate once those controllers are wired into +// the wallet initialization (both still depend on AccountsController at the +// messenger level, so it must remain present in the meantime). +type AllowedActions = MessengerActions; + +type AllowedEvents = MessengerEvents; + +export const accountsController: InitializationConfiguration< + AccountsController, + AccountsControllerMessenger +> = { + name: 'AccountsController', + init: ({ state, messenger }) => + new AccountsController({ + state: (state ?? {}) as AccountsControllerState, + messenger, + }), + getMessenger: (parent: RootMessenger) => { + const accountsControllerMessenger = new Messenger< + 'AccountsController', + AllowedActions, + AllowedEvents, + typeof parent + >({ + namespace: 'AccountsController', + parent, + }); + + parent.delegate({ + messenger: accountsControllerMessenger, + actions: [ + 'KeyringController:getState', + 'KeyringController:getKeyringsByType', + ], + events: [ + // AccountsController subscribes to :stateChange internally; the + // delegation must match until that package migrates to :stateChanged. + // eslint-disable-next-line no-restricted-syntax + 'KeyringController:stateChange', + 'SnapKeyring:accountAssetListUpdated', + 'SnapKeyring:accountBalancesUpdated', + 'SnapKeyring:accountTransactionsUpdated', + 'MultichainNetworkController:networkDidChange', + ], + }); + + return accountsControllerMessenger; + }, +}; diff --git a/packages/wallet/src/initialization/instances/connectivity-controller.test.ts b/packages/wallet/src/initialization/instances/connectivity-controller.test.ts new file mode 100644 index 0000000000..39f8904faf --- /dev/null +++ b/packages/wallet/src/initialization/instances/connectivity-controller.test.ts @@ -0,0 +1,51 @@ +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; +import { Messenger } from '@metamask/messenger'; + +import { + AlwaysOnlineAdapter, + connectivityController, +} from './connectivity-controller'; + +describe('AlwaysOnlineAdapter', () => { + it('returns Online from getStatus', async () => { + const adapter = new AlwaysOnlineAdapter(); + const status = await adapter.getStatus(); + + expect(status).toBe(CONNECTIVITY_STATUSES.Online); + }); + + it('onConnectivityChange is a no-op', () => { + const adapter = new AlwaysOnlineAdapter(); + const callback = jest.fn(); + + adapter.onConnectivityChange(callback); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('destroy is a no-op', () => { + const adapter = new AlwaysOnlineAdapter(); + + expect(() => adapter.destroy()).not.toThrow(); + }); +}); + +describe('connectivityController', () => { + it('reports online status after initialization', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parent = new Messenger<'Root', any, any>({ namespace: 'Root' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const messenger = connectivityController.getMessenger(parent as any); + const controller = connectivityController.init({ + messenger, + state: undefined, + options: {}, + }); + + await controller.init(); + + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Online, + ); + }); +}); diff --git a/packages/wallet/src/initialization/instances/connectivity-controller.ts b/packages/wallet/src/initialization/instances/connectivity-controller.ts new file mode 100644 index 0000000000..060f6168f4 --- /dev/null +++ b/packages/wallet/src/initialization/instances/connectivity-controller.ts @@ -0,0 +1,65 @@ +import { + CONNECTIVITY_STATUSES, + ConnectivityAdapter, + ConnectivityController, + ConnectivityControllerMessenger, + ConnectivityStatus, +} from '@metamask/connectivity-controller'; +import { Messenger } from '@metamask/messenger'; + +import type { DefaultActions, DefaultEvents, RootMessenger } from '../defaults'; +import type { InitializationConfiguration } from '../types'; + +/** + * A connectivity adapter that unconditionally reports the device as online. + * + * This is a temporary placeholder until a real platform-specific adapter + * (one that observes actual network events) is injected by the consumer. + */ +export class AlwaysOnlineAdapter implements ConnectivityAdapter { + /** + * Returns the current connectivity status. + * + * @returns A promise that always resolves to the online status. + */ + async getStatus(): Promise { + return CONNECTIVITY_STATUSES.Online; + } + + /** + * Registers a callback for connectivity changes. + * + * This adapter never changes status, so the callback is never invoked. + * + * @param _callback - The callback to register. + */ + onConnectivityChange(_callback: (status: ConnectivityStatus) => void): void { + // no-op + } + + /** + * Cleans up any resources held by this adapter. + * + * This adapter holds no resources, so this is a no-op. + */ + destroy(): void { + // no-op + } +} + +export const connectivityController: InitializationConfiguration< + ConnectivityController, + ConnectivityControllerMessenger +> = { + name: 'ConnectivityController', + init: ({ messenger }) => + new ConnectivityController({ + messenger, + connectivityAdapter: new AlwaysOnlineAdapter(), + }), + getMessenger: (parent: RootMessenger) => + new Messenger<'ConnectivityController', never, never, typeof parent>({ + namespace: 'ConnectivityController', + parent, + }), +}; diff --git a/packages/wallet/src/initialization/instances/index.ts b/packages/wallet/src/initialization/instances/index.ts index 28a3bf2f23..648fa5e402 100644 --- a/packages/wallet/src/initialization/instances/index.ts +++ b/packages/wallet/src/initialization/instances/index.ts @@ -1 +1,3 @@ +export { accountsController } from './accounts-controller'; +export { connectivityController } from './connectivity-controller'; export { keyringController } from './keyring-controller'; diff --git a/packages/wallet/src/utilities.test.ts b/packages/wallet/src/utilities.test.ts new file mode 100644 index 0000000000..37b1f804bb --- /dev/null +++ b/packages/wallet/src/utilities.test.ts @@ -0,0 +1,31 @@ +import { webcrypto } from 'crypto'; + +import { createSecretRecoveryPhrase } from './utilities'; +import { Wallet } from './Wallet'; + +const TEST_PASSWORD = 'testpass'; + +describe('createSecretRecoveryPhrase', () => { + beforeAll(() => { + // We can remove this once we drop Node 18 + // eslint-disable-next-line n/no-unsupported-features/node-builtins + globalThis.crypto ??= webcrypto as typeof globalThis.crypto; + + // eslint-disable-next-line no-restricted-syntax + if (!('CryptoKey' in globalThis)) { + Object.defineProperty(globalThis, 'CryptoKey', { + value: webcrypto.CryptoKey, + }); + } + }); + + it('creates a vault and populates accounts', async () => { + const wallet = new Wallet({}); + + await createSecretRecoveryPhrase(wallet, TEST_PASSWORD); + + expect( + await wallet.messenger.call('KeyringController:getAccounts'), + ).toHaveLength(1); + }); +}); diff --git a/packages/wallet/src/utilities.ts b/packages/wallet/src/utilities.ts index 4630dd5460..e3f9814fda 100644 --- a/packages/wallet/src/utilities.ts +++ b/packages/wallet/src/utilities.ts @@ -24,3 +24,20 @@ export async function importSecretRecoveryPhrase( mnemonic, ); } + +/** + * Initialize the wallet object with a randomly generated secret recovery phrase. + * + * @param wallet - The wallet object. + * @param password - The password to the MetaMask wallet (not the SRP). + */ +export async function createSecretRecoveryPhrase( + wallet: Wallet, + password: string, +): Promise { + // TODO: This should use the new MultichainAccountService. + await wallet.messenger.call( + 'KeyringController:createNewVaultAndKeychain', + password, + ); +} diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json index b16ce7cfcd..a971bc355d 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -6,7 +6,9 @@ "rootDir": "./src" }, "references": [ + { "path": "../accounts-controller/tsconfig.build.json" }, { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../connectivity-controller/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" } ],