diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 59fb52d8e9..5ed040034b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -140,6 +140,7 @@ /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/ @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/wallet/src/initialization/instances/preferences-controller/ @MetaMask/core-platform /packages/wallet/src/initialization/instances/remote-feature-flag-controller/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/wallet/src/initialization/instances/storage-service/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform diff --git a/README.md b/README.md index bf2f4335a3..2f3416849f 100644 --- a/README.md +++ b/README.md @@ -614,6 +614,7 @@ linkStyle default opacity:0.5 wallet --> controller_utils; wallet --> keyring_controller; wallet --> messenger; + wallet --> preferences_controller; wallet --> remote_feature_flag_controller; wallet --> storage_service; wallet_cli --> base_controller; diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 395e3257b5..a9a224e9df 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING:** Wire `PreferencesController` into the default wallet initialization ([#9232](https://github.com/MetaMask/core/pull/9232)) + - The default `Wallet` now constructs a `PreferencesController` and registers its `PreferencesController:*` messenger actions. Consumers that pass their own `messenger` and already wire a `PreferencesController` must remove their own before upgrading, or the duplicate registration will collide. +- Export the `InitializationConfiguration`, `InitFunctionArguments`, and `InstanceState` types so consumers can author initialization configurations that override a default controller ([#9232](https://github.com/MetaMask/core/pull/9232)) + ### Changed - Bump `@metamask/accounts-controller` from `^39.0.2` to `^39.0.3` ([#9231](https://github.com/MetaMask/core/pull/9231)) diff --git a/packages/wallet/package.json b/packages/wallet/package.json index a60081b716..fce703bf49 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -61,6 +61,7 @@ "@metamask/controller-utils": "^12.3.0", "@metamask/keyring-controller": "^27.1.0", "@metamask/messenger": "^1.2.0", + "@metamask/preferences-controller": "^23.1.0", "@metamask/remote-feature-flag-controller": "^4.2.2", "@metamask/scure-bip39": "^2.1.1", "@metamask/storage-service": "^1.0.2", diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index a89602c3fc..697870dcfc 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -1,5 +1,6 @@ import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; import { Messenger } from '@metamask/messenger'; +import { getDefaultPreferencesState } from '@metamask/preferences-controller'; import { InMemoryStorageAdapter } from '@metamask/storage-service'; import { Json } from '@metamask/utils'; import { webcrypto } from 'crypto'; @@ -321,6 +322,98 @@ describe('Wallet', () => { }); }); + describe('PreferencesController', () => { + it('is wired and exposes its state on the wallet messenger', async () => { + const wallet = await setupWallet(); + const { messenger } = wallet; + + expect(messenger.call('PreferencesController:getState')).toStrictEqual( + getDefaultPreferencesState(), + ); + }); + + it('applies initial state passed through the Wallet constructor', () => { + const wallet = new Wallet({ + state: { + PreferencesController: { privacyMode: true }, + }, + instanceOptions: { + connectivityController: { + connectivityAdapter: new AlwaysOnlineAdapter(), + }, + storageService: { + storage: new InMemoryStorageAdapter(), + }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, + }, + }); + + expect(wallet.state.PreferencesController.privacyMode).toBe(true); + }); + + it('routes its method actions through the wallet messenger', async () => { + const wallet = await setupWallet(); + const { messenger } = wallet; + + messenger.call( + 'PreferencesController:setIpfsGateway', + 'https://example.com/ipfs/', + ); + + expect(wallet.state.PreferencesController.ipfsGateway).toBe( + 'https://example.com/ipfs/', + ); + }); + + it('lets a consumer override the default with a diverging superset controller', () => { + // A client (e.g. the extension) whose local PreferencesController is a + // superset of the package one can keep it by supplying its own + // `PreferencesController` initialization configuration. The same `name` + // overrides the package default, so the wallet constructs the superset + // instead of the package controller — no convergence required. + class SupersetPreferencesController { + state = { + ipfsGateway: 'https://superset.example/ipfs/', + currentLocale: 'en', + preferences: { showTestNetworks: true }, + }; + } + + const wallet = new Wallet({ + initializationConfigurations: [ + { + name: 'PreferencesController', + getMessenger: (): Messenger => + new Messenger({ namespace: 'PreferencesController' }), + init: (): SupersetPreferencesController => + new SupersetPreferencesController(), + }, + ], + instanceOptions: { + connectivityController: { + connectivityAdapter: new AlwaysOnlineAdapter(), + }, + storageService: { + storage: new InMemoryStorageAdapter(), + }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, + }, + }); + + expect(wallet.getInstance('PreferencesController')).toBeInstanceOf( + SupersetPreferencesController, + ); + // The superset's shape — including fields absent from the package + // controller (`currentLocale`, nested `preferences`) — is what the wallet + // exposes, not the package defaults. + expect(wallet.state.PreferencesController).toStrictEqual({ + ipfsGateway: 'https://superset.example/ipfs/', + currentLocale: 'en', + preferences: { showTestNetworks: true }, + }); + }); + }); + describe('StorageService', () => { it('can set and get items', async () => { const wallet = await setupWallet(); diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index 337ad6ed7f..c6939ed9f3 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -9,3 +9,8 @@ export type { DefaultState, RootMessenger, } from './initialization/defaults'; +export type { + InitFunctionArguments, + InitializationConfiguration, + InstanceState, +} from './initialization/types'; diff --git a/packages/wallet/src/initialization/instances/index.ts b/packages/wallet/src/initialization/instances/index.ts index bed6e52b26..5908981b3b 100644 --- a/packages/wallet/src/initialization/instances/index.ts +++ b/packages/wallet/src/initialization/instances/index.ts @@ -2,5 +2,6 @@ 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/keyring-controller'; +export { preferencesController } from './preferences-controller/preferences-controller'; export { remoteFeatureFlagController } from './remote-feature-flag-controller/remote-feature-flag-controller'; export { storageService } from './storage-service/storage-service'; diff --git a/packages/wallet/src/initialization/instances/preferences-controller/preferences-controller.test.ts b/packages/wallet/src/initialization/instances/preferences-controller/preferences-controller.test.ts new file mode 100644 index 0000000000..3cd5a76ff1 --- /dev/null +++ b/packages/wallet/src/initialization/instances/preferences-controller/preferences-controller.test.ts @@ -0,0 +1,88 @@ +import { Messenger } from '@metamask/messenger'; +import { + PreferencesController, + getDefaultPreferencesState, +} from '@metamask/preferences-controller'; + +import { defaultConfigurations } from '../../defaults'; +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../../defaults'; +import { preferencesController } from './preferences-controller'; + +/** + * Creates a root messenger for use in tests. + * + * @returns A root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: 'Root' }); +} + +describe('preferencesController', () => { + it('is registered as a default initialization configuration', () => { + expect(Object.values(defaultConfigurations)).toContain( + preferencesController, + ); + }); + + it('initializes a PreferencesController with default state', () => { + const messenger = preferencesController.getMessenger(getRootMessenger()); + + const instance = preferencesController.init({ + state: undefined, + messenger, + options: {}, + }); + + expect(instance).toBeInstanceOf(PreferencesController); + expect(instance.state).toStrictEqual(getDefaultPreferencesState()); + }); + + it('merges provided state over the defaults', () => { + const messenger = preferencesController.getMessenger(getRootMessenger()); + + const instance = preferencesController.init({ + state: { ipfsGateway: 'https://example.com/ipfs/', privacyMode: true }, + messenger, + options: {}, + }); + + expect(instance.state.ipfsGateway).toBe('https://example.com/ipfs/'); + expect(instance.state.privacyMode).toBe(true); + expect(instance.state.useTokenDetection).toBe( + getDefaultPreferencesState().useTokenDetection, + ); + }); + + it('exposes its state through the root messenger', () => { + const rootMessenger = getRootMessenger(); + const messenger = preferencesController.getMessenger(rootMessenger); + + preferencesController.init({ state: undefined, messenger, options: {} }); + + expect(rootMessenger.call('PreferencesController:getState')).toStrictEqual( + getDefaultPreferencesState(), + ); + }); + + it('registers its method actions on the root messenger', () => { + const rootMessenger = getRootMessenger(); + const messenger = preferencesController.getMessenger(rootMessenger); + + const instance = preferencesController.init({ + state: undefined, + messenger, + options: {}, + }); + + rootMessenger.call( + 'PreferencesController:setIpfsGateway', + 'https://x/ipfs/', + ); + + expect(instance.state.ipfsGateway).toBe('https://x/ipfs/'); + }); +}); diff --git a/packages/wallet/src/initialization/instances/preferences-controller/preferences-controller.ts b/packages/wallet/src/initialization/instances/preferences-controller/preferences-controller.ts new file mode 100644 index 0000000000..e56dc99ce1 --- /dev/null +++ b/packages/wallet/src/initialization/instances/preferences-controller/preferences-controller.ts @@ -0,0 +1,24 @@ +import { Messenger } from '@metamask/messenger'; +import { + PreferencesController, + PreferencesControllerMessenger, +} from '@metamask/preferences-controller'; + +import type { InitializationConfiguration } from '../../types'; + +export const preferencesController: InitializationConfiguration< + PreferencesController, + PreferencesControllerMessenger +> = { + name: 'PreferencesController', + init: ({ state, messenger }) => + new PreferencesController({ + state, + messenger, + }), + getMessenger: (parent) => + new Messenger({ + namespace: 'PreferencesController', + parent, + }), +}; diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json index 317fe28aac..1b60c8333e 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -13,6 +13,7 @@ { "path": "../controller-utils/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, + { "path": "../preferences-controller/tsconfig.build.json" }, { "path": "../remote-feature-flag-controller/tsconfig.build.json" }, { "path": "../storage-service/tsconfig.build.json" } ], diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json index abbd559b3e..5660908fe9 100644 --- a/packages/wallet/tsconfig.json +++ b/packages/wallet/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../controller-utils/tsconfig.json" }, { "path": "../keyring-controller/tsconfig.json" }, { "path": "../messenger/tsconfig.json" }, + { "path": "../preferences-controller/tsconfig.json" }, { "path": "../remote-feature-flag-controller/tsconfig.json" }, { "path": "../storage-service/tsconfig.json" } ], diff --git a/yarn.lock b/yarn.lock index 9fc2eab3c2..4c8bd7576f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8938,6 +8938,7 @@ __metadata: "@metamask/controller-utils": "npm:^12.3.0" "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/messenger": "npm:^1.2.0" + "@metamask/preferences-controller": "npm:^23.1.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/storage-service": "npm:^1.0.2"