From 6eeff1d31afbe4728f33a9727a219dfe4820ca2b Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 23 Jun 2026 13:27:54 +0200 Subject: [PATCH 1/4] feat(wallet): wire `PreferencesController` into default initialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `PreferencesController` to the default wallet initialization set. It has no cross-controller messenger dependencies and its only constructor inputs are `messenger` and `state`, so it wires with a plain namespaced child messenger and no `instanceOptions` slot — per-client values are supplied through the existing `state.PreferencesController` initial state. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/CODEOWNERS | 1 + README.md | 1 + packages/wallet/CHANGELOG.md | 5 ++ packages/wallet/package.json | 1 + packages/wallet/src/Wallet.test.ts | 45 ++++++++++ .../src/initialization/instances/index.ts | 1 + .../preferences-controller.test.ts | 88 +++++++++++++++++++ .../preferences-controller.ts | 24 +++++ packages/wallet/tsconfig.build.json | 1 + packages/wallet/tsconfig.json | 1 + yarn.lock | 1 + 11 files changed, 169 insertions(+) create mode 100644 packages/wallet/src/initialization/instances/preferences-controller/preferences-controller.test.ts create mode 100644 packages/wallet/src/initialization/instances/preferences-controller/preferences-controller.ts 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..c28235fc3e 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING:** Wire `PreferencesController` into the default wallet initialization ([#9231](https://github.com/MetaMask/core/pull/9231)) + - 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. + ### 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..7b5d3a53d9 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,50 @@ 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/', + ); + }); + }); + describe('StorageService', () => { it('can set and get items', async () => { const wallet = await setupWallet(); 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" From a8fd340ffa40c16dd224204e0351812887163228 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 23 Jun 2026 13:35:18 +0200 Subject: [PATCH 2/4] docs(wallet): point changelog entry at PR #9232 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index c28235fc3e..f4ebdf6fb1 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **BREAKING:** Wire `PreferencesController` into the default wallet initialization ([#9231](https://github.com/MetaMask/core/pull/9231)) +- **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. ### Changed From 7497f9c38a54e189a7bdcdd84aab05b585037abd Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 23 Jun 2026 18:28:36 +0200 Subject: [PATCH 3/4] test(wallet): cover consumer override of default PreferencesController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prove a consumer can keep a diverging superset PreferencesController by supplying its own initialization configuration under the same name, which overrides the package default — the adoption path for clients (e.g. the extension) that cannot converge to the package controller's state shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet/src/Wallet.test.ts | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 7b5d3a53d9..697870dcfc 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -364,6 +364,54 @@ describe('Wallet', () => { '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', () => { From 55f166437b16fa6c7cf5fe11e5e543e93ec7fee1 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 23 Jun 2026 19:42:04 +0200 Subject: [PATCH 4/4] feat(wallet): export `InitializationConfiguration` and friends Re-export `InitializationConfiguration`, `InitFunctionArguments`, and `InstanceState` from the package root so consumers can annotate their own initialization configurations that override a default controller (e.g. the extension overriding the default `PreferencesController`). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet/CHANGELOG.md | 1 + packages/wallet/src/index.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index f4ebdf6fb1..a9a224e9df 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 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';