diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e228d8fb4b..b4eafeb5ac 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -129,7 +129,9 @@ /packages/profile-metrics-controller @MetaMask/mobile-platform @MetaMask/extension-platform ## Initialization +/packages/wallet/src/initialization/instances/accounts-controller/ @MetaMask/accounts-engineers /packages/wallet/src/initialization/instances/approval-controller/ @MetaMask/confirmations +/packages/wallet/src/initialization/instances/connectivity-controller/ @MetaMask/core-platform /packages/wallet/src/initialization/instances/keyring-controller.ts @MetaMask/accounts-engineers @MetaMask/core-platform ## Package Release related diff --git a/README.md b/README.md index 54235840db..eb193da7e0 100644 --- a/README.md +++ b/README.md @@ -567,8 +567,10 @@ linkStyle default opacity:0.5 user_operation_controller --> polling_controller; user_operation_controller --> transaction_controller; user_operation_controller --> eth_block_tracker; + wallet --> accounts_controller; wallet --> approval_controller; wallet --> base_controller; + wallet --> connectivity_controller; wallet --> controller_utils; wallet --> keyring_controller; wallet --> messenger; diff --git a/packages/accounts-controller/CHANGELOG.md b/packages/accounts-controller/CHANGELOG.md index 812cb46320..706ea115fe 100644 --- a/packages/accounts-controller/CHANGELOG.md +++ b/packages/accounts-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- The `state` option of the `AccountsController` constructor is now optional, defaulting to the controller's default state when omitted ([#8924](https://github.com/MetaMask/core/pull/8924)) + ## [38.1.2] ### Changed diff --git a/packages/accounts-controller/src/AccountsController.ts b/packages/accounts-controller/src/AccountsController.ts index e5216b49b1..db2e29095a 100644 --- a/packages/accounts-controller/src/AccountsController.ts +++ b/packages/accounts-controller/src/AccountsController.ts @@ -327,7 +327,7 @@ export class AccountsController extends BaseController< state, }: { messenger: AccountsControllerMessenger; - state: AccountsControllerState; + state?: AccountsControllerState; }) { const accountIdByAddress = constructAccountIdByAddress( state?.internalAccounts?.accounts ?? {}, diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 787705fe66..98d7751cc9 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **BREAKING:** Add `AccountsController` and `ConnectivityController` as default initialized controllers ([#8924](https://github.com/MetaMask/core/pull/8924)) + - Passing `instanceOptions.connectivityController.connectivityAdapter` is now required. - **BREAKING:** Wire `ApprovalController` into the default wallet initialization ([#8953](https://github.com/MetaMask/core/pull/8953)) - The default `Wallet` now constructs an `ApprovalController` and registers its `ApprovalController:*` messenger actions. Consumers that pass their own `messenger` and already wire an `ApprovalController` must remove their own before upgrading, or the duplicate registration will collide. - Adds an `approvalController` slot to `instanceOptions` with `showApprovalRequest` (the callback that surfaces pending approval requests to the user; defaults to a no-op) and `typesExcludedFromRateLimiting` (the approval types exempt from per-origin rate limiting; defaults to a baseline of EVM approval types). Both let consumers (extension, mobile, wallet-cli) inject their platform-specific values. diff --git a/packages/wallet/package.json b/packages/wallet/package.json index f4f1414434..a8a13e1486 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -53,9 +53,11 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/accounts-controller": "^38.1.2", "@metamask/approval-controller": "^9.0.1", "@metamask/base-controller": "^9.1.0", "@metamask/browser-passworder": "^6.0.0", + "@metamask/connectivity-controller": "^0.2.0", "@metamask/controller-utils": "^12.1.0", "@metamask/keyring-controller": "^26.0.0", "@metamask/messenger": "^1.2.0", diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index e0ff366c09..aa2447b37a 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -1,3 +1,4 @@ +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; import { Messenger } from '@metamask/messenger'; import { InMemoryStorageAdapter } from '@metamask/storage-service'; import { Json } from '@metamask/utils'; @@ -5,6 +6,7 @@ import { webcrypto } from 'crypto'; import MockEncryptor from '../../keyring-controller/tests/mocks/mockEncryptor'; import * as initializationModule from './initialization/initialization'; +import { AlwaysOnlineAdapter } from './initialization/instances/connectivity-controller/always-online-adapter'; import { importSecretRecoveryPhrase } from './utilities'; import { Wallet } from './Wallet'; @@ -14,6 +16,9 @@ const TEST_PASSWORD = 'testpass'; async function setupWallet(): Promise { const wallet = new Wallet({ instanceOptions: { + connectivityController: { + connectivityAdapter: new AlwaysOnlineAdapter(), + }, storageService: { storage: new InMemoryStorageAdapter(), }, @@ -67,6 +72,9 @@ describe('Wallet', () => { it('supports passing instance options', async () => { const wallet = new Wallet({ instanceOptions: { + connectivityController: { + connectivityAdapter: new AlwaysOnlineAdapter(), + }, keyringController: { encryptor: new MockEncryptor(), }, @@ -112,6 +120,9 @@ describe('Wallet', () => { }, ], instanceOptions: { + connectivityController: { + connectivityAdapter: new AlwaysOnlineAdapter(), + }, storageService: { storage: new InMemoryStorageAdapter(), }, @@ -146,7 +157,16 @@ describe('Wallet', () => { NoMeta: { state: {} }, }); - const wallet = new Wallet({}); + const wallet = new Wallet({ + instanceOptions: { + connectivityController: { + connectivityAdapter: new AlwaysOnlineAdapter(), + }, + storageService: { + storage: new InMemoryStorageAdapter(), + }, + }, + }); expect(wallet.controllerMetadata).toStrictEqual({ WithMeta: fakeMetadata, @@ -191,6 +211,40 @@ describe('Wallet', () => { expect(spy).toHaveBeenCalledTimes(1); }); + describe('AccountsController', () => { + it('tracks accounts created via KeyringController', async () => { + const wallet = await setupWallet(); + + 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', () => { + const wallet = new Wallet({ + instanceOptions: { + connectivityController: { + connectivityAdapter: new AlwaysOnlineAdapter(), + }, + storageService: { + storage: new InMemoryStorageAdapter(), + }, + }, + }); + + expect(wallet.state.ConnectivityController.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Online, + ); + }); + }); + describe('KeyringController', () => { it('can unlock and populate accounts', async () => { const wallet = await setupWallet(); @@ -212,6 +266,9 @@ describe('Wallet', () => { }, }, instanceOptions: { + connectivityController: { + connectivityAdapter: new AlwaysOnlineAdapter(), + }, storageService: { storage: new InMemoryStorageAdapter(), }, diff --git a/packages/wallet/src/initialization/instances/accounts-controller/accounts-controller.ts b/packages/wallet/src/initialization/instances/accounts-controller/accounts-controller.ts new file mode 100644 index 0000000000..89023affb3 --- /dev/null +++ b/packages/wallet/src/initialization/instances/accounts-controller/accounts-controller.ts @@ -0,0 +1,51 @@ +import { + AccountsController, + AccountsControllerMessenger, +} from '@metamask/accounts-controller'; +import { Messenger } from '@metamask/messenger'; + +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../../defaults'; +import type { InitializationConfiguration } from '../../types'; + +export const accountsController: InitializationConfiguration< + AccountsController, + AccountsControllerMessenger +> = { + name: 'AccountsController', + init: ({ state, messenger }) => + new AccountsController({ + state, + messenger, + }), + getMessenger: (parent: RootMessenger) => { + const accountsControllerMessenger: AccountsControllerMessenger = + new Messenger({ + 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/always-online-adapter.test.ts b/packages/wallet/src/initialization/instances/connectivity-controller/always-online-adapter.test.ts new file mode 100644 index 0000000000..417438a541 --- /dev/null +++ b/packages/wallet/src/initialization/instances/connectivity-controller/always-online-adapter.test.ts @@ -0,0 +1,27 @@ +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; + +import { AlwaysOnlineAdapter } from './always-online-adapter'; + +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(); + }); +}); diff --git a/packages/wallet/src/initialization/instances/connectivity-controller/always-online-adapter.ts b/packages/wallet/src/initialization/instances/connectivity-controller/always-online-adapter.ts new file mode 100644 index 0000000000..9630eae0f6 --- /dev/null +++ b/packages/wallet/src/initialization/instances/connectivity-controller/always-online-adapter.ts @@ -0,0 +1,42 @@ +import { + CONNECTIVITY_STATUSES, + ConnectivityAdapter, + ConnectivityStatus, +} from '@metamask/connectivity-controller'; + +/** + * 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 + } +} diff --git a/packages/wallet/src/initialization/instances/connectivity-controller/connectivity-controller.test.ts b/packages/wallet/src/initialization/instances/connectivity-controller/connectivity-controller.test.ts new file mode 100644 index 0000000000..5cc10d0976 --- /dev/null +++ b/packages/wallet/src/initialization/instances/connectivity-controller/connectivity-controller.test.ts @@ -0,0 +1,23 @@ +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; +import { Messenger } from '@metamask/messenger'; + +import { AlwaysOnlineAdapter } from './always-online-adapter'; +import { connectivityController } from './connectivity-controller'; + +describe('connectivityController', () => { + it('reports online status after initialization', () => { + // 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: { connectivityAdapter: new AlwaysOnlineAdapter() }, + }); + + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Online, + ); + }); +}); diff --git a/packages/wallet/src/initialization/instances/connectivity-controller/connectivity-controller.ts b/packages/wallet/src/initialization/instances/connectivity-controller/connectivity-controller.ts new file mode 100644 index 0000000000..43f681879e --- /dev/null +++ b/packages/wallet/src/initialization/instances/connectivity-controller/connectivity-controller.ts @@ -0,0 +1,29 @@ +import { + ConnectivityController, + ConnectivityControllerMessenger, +} from '@metamask/connectivity-controller'; +import { Messenger } from '@metamask/messenger'; + +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../../defaults'; +import type { InitializationConfiguration } from '../../types'; + +export const connectivityController: InitializationConfiguration< + ConnectivityController, + ConnectivityControllerMessenger +> = { + name: 'ConnectivityController', + init: ({ messenger, options }) => + new ConnectivityController({ + messenger, + connectivityAdapter: options.connectivityAdapter, + }), + getMessenger: (parent: RootMessenger) => + new Messenger({ + namespace: 'ConnectivityController', + parent, + }), +}; diff --git a/packages/wallet/src/initialization/instances/connectivity-controller/types.ts b/packages/wallet/src/initialization/instances/connectivity-controller/types.ts new file mode 100644 index 0000000000..e7df03a212 --- /dev/null +++ b/packages/wallet/src/initialization/instances/connectivity-controller/types.ts @@ -0,0 +1,13 @@ +import type { ConnectivityAdapter } from '@metamask/connectivity-controller'; + +/** + * Per-instance options for the wallet's `ConnectivityController`. + */ +export type ConnectivityControllerInstanceOptions = { + /** + * Platform-specific adapter that observes the device's network connectivity. + * Required because connectivity is inherently platform-specific; node-like + * environments can pass the exported `AlwaysOnlineAdapter`. + */ + connectivityAdapter: ConnectivityAdapter; +}; diff --git a/packages/wallet/src/initialization/instances/index.ts b/packages/wallet/src/initialization/instances/index.ts index 56cb78a012..8042e64a03 100644 --- a/packages/wallet/src/initialization/instances/index.ts +++ b/packages/wallet/src/initialization/instances/index.ts @@ -1,3 +1,5 @@ +export { accountsController } from './accounts-controller/accounts-controller'; export { approvalController } from './approval-controller/approval-controller'; +export { connectivityController } from './connectivity-controller/connectivity-controller'; export { keyringController } from './keyring-controller'; export { storageService } from './storage-service'; diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index a95699f8ef..198ae04542 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -8,6 +8,7 @@ import type { RootMessenger, } from './initialization/defaults'; import type { ApprovalControllerInstanceOptions } from './initialization/instances/approval-controller/types'; +import type { ConnectivityControllerInstanceOptions } from './initialization/instances/connectivity-controller/types'; import { GenericEncryptor } from './initialization/instances/keyring-controller'; import { InitializationConfiguration } from './initialization/types'; @@ -23,6 +24,7 @@ export type WalletOptions = { export type InstanceSpecificOptions = { approvalController?: ApprovalControllerInstanceOptions; + connectivityController: ConnectivityControllerInstanceOptions; keyringController?: { encryptor?: GenericEncryptor; keyringBuilders?: KeyringControllerOptions['keyringBuilders']; diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json index 68e89ef628..273beb026a 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -6,8 +6,10 @@ "rootDir": "./src" }, "references": [ + { "path": "../accounts-controller/tsconfig.build.json" }, { "path": "../approval-controller/tsconfig.build.json" }, { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../connectivity-controller/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, diff --git a/yarn.lock b/yarn.lock index fa1830cbf7..e53e5a9e5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5887,10 +5887,12 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/wallet@workspace:packages/wallet" dependencies: + "@metamask/accounts-controller": "npm:^38.1.2" "@metamask/approval-controller": "npm:^9.0.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/browser-passworder": "npm:^6.0.0" + "@metamask/connectivity-controller": "npm:^0.2.0" "@metamask/controller-utils": "npm:^12.1.0" "@metamask/keyring-controller": "npm:^26.0.0" "@metamask/messenger": "npm:^1.2.0"