From 0b94d6803273d71a262adcf827631c203d7f145b Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 18 May 2026 10:31:56 +0200 Subject: [PATCH 01/26] feat: Implement wallet initialization library --- packages/wallet/package.json | 5 ++ packages/wallet/src/Wallet.ts | 85 +++++++++++++++++++ packages/wallet/src/index.test.ts | 9 -- packages/wallet/src/index.ts | 17 ++-- .../wallet/src/initialization/defaults.ts | 57 +++++++++++++ packages/wallet/src/initialization/index.ts | 14 +++ .../src/initialization/initialization.ts | 45 ++++++++++ packages/wallet/src/initialization/types.ts | 22 +++++ packages/wallet/src/types.ts | 11 +++ packages/wallet/tsconfig.build.json | 5 +- packages/wallet/tsconfig.json | 5 +- yarn.lock | 3 + 12 files changed, 258 insertions(+), 20 deletions(-) create mode 100644 packages/wallet/src/Wallet.ts delete mode 100644 packages/wallet/src/index.test.ts create mode 100644 packages/wallet/src/initialization/defaults.ts create mode 100644 packages/wallet/src/initialization/index.ts create mode 100644 packages/wallet/src/initialization/initialization.ts create mode 100644 packages/wallet/src/initialization/types.ts create mode 100644 packages/wallet/src/types.ts diff --git a/packages/wallet/package.json b/packages/wallet/package.json index e067b95767..737e401b67 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -52,6 +52,11 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "@metamask/base-controller": "^9.1.0", + "@metamask/messenger": "^1.2.0", + "@metamask/utils": "^11.11.0" + }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", "@ts-bridge/cli": "^0.6.4", diff --git a/packages/wallet/src/Wallet.ts b/packages/wallet/src/Wallet.ts new file mode 100644 index 0000000000..d1d893bcf2 --- /dev/null +++ b/packages/wallet/src/Wallet.ts @@ -0,0 +1,85 @@ +import type { StateMetadataConstraint } from '@metamask/base-controller'; +import { Messenger } from '@metamask/messenger'; +import { hasProperty } from '@metamask/utils'; + +import type { + DefaultActions, + DefaultEvents, + DefaultInstances, + DefaultState, + RootMessenger, +} from './initialization'; +import { initialize } from './initialization'; +import { WalletOptions } from './types'; + +export class Wallet { + // TODO: Expand types when passing additionalConfigurations. + readonly #messenger: RootMessenger; + + readonly #instances: DefaultInstances; + + readonly #controllerMetadata: Readonly< + Record> + >; + + #destroyed = false; + + constructor(options: WalletOptions) { + this.#messenger = new Messenger({ + namespace: 'Wallet', + }); + + this.#instances = initialize({ + options, + messenger: this.#messenger, + }); + + this.#controllerMetadata = Object.fromEntries( + Object.entries(this.#instances) + .filter(([_, instance]) => hasProperty(instance, 'metadata')) + .map(([name, instance]) => [name, instance.metadata]), + ); + } + + get messenger(): Readonly> { + return this.#messenger; + } + + get state(): DefaultState { + return Object.entries(this.#instances).reduce>( + (totalState, [name, instance]) => { + totalState[name] = instance.state ?? null; + return totalState; + }, + {}, + ) as DefaultState; + } + + get controllerMetadata(): Readonly< + Record> + > { + return this.#controllerMetadata; + } + + async destroy(): Promise { + if (this.#destroyed) { + return; + } + + this.#destroyed = true; + + await Promise.allSettled( + Object.values(this.#instances).map(async (instance) => { + // @ts-expect-error Accessing protected property. + if (typeof instance.destroy === 'function') { + // @ts-expect-error Accessing protected property. + return await instance.destroy(); + } + /* istanbul ignore next */ + return undefined; + }), + ); + + this.messenger.publish('Wallet:destroyed'); + } +} diff --git a/packages/wallet/src/index.test.ts b/packages/wallet/src/index.test.ts deleted file mode 100644 index bc062d3694..0000000000 --- a/packages/wallet/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index 6972c11729..5fa0502ec2 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -1,9 +1,8 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} +export { Wallet } from './Wallet'; +export type { WalletOptions } from './types'; +export type { + DefaultActions, + DefaultEvents, + RootMessenger, + WalletDestroyedEvent, +} from './initialization'; diff --git a/packages/wallet/src/initialization/defaults.ts b/packages/wallet/src/initialization/defaults.ts new file mode 100644 index 0000000000..7156aad7a0 --- /dev/null +++ b/packages/wallet/src/initialization/defaults.ts @@ -0,0 +1,57 @@ +import type { + ActionConstraint, + EventConstraint, + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import * as defaultConfigurations from './instances'; +import type { InitializationConfiguration, InstanceState } from './types'; + +export { defaultConfigurations }; + +type ExtractInstance = + Config extends InitializationConfiguration + ? Instance + : never; + +type ExtractInstanceMessenger = + Config extends InitializationConfiguration + ? InferredMessenger + : never; + +type ExtractName = + ExtractInstance extends { name: infer Name extends string } + ? Name + : never; + +type Configs = typeof defaultConfigurations; + +type AllMessengers = ExtractInstanceMessenger; + +export type DefaultInstances = { + [Key in keyof Configs as ExtractName]: ExtractInstance< + Configs[Key] + >; +}; + +export type DefaultActions = MessengerActions; + +export type WalletDestroyedEvent = { + type: 'Wallet:destroyed'; + payload: []; +}; + +export type DefaultEvents = + | MessengerEvents + | WalletDestroyedEvent; + +export type RootMessenger< + AllowedActions extends ActionConstraint = ActionConstraint, + AllowedEvents extends EventConstraint = EventConstraint, +> = Messenger<'Wallet', AllowedActions, AllowedEvents>; + +export type DefaultState = { + [Key in keyof DefaultInstances]: InstanceState; +}; diff --git a/packages/wallet/src/initialization/index.ts b/packages/wallet/src/initialization/index.ts new file mode 100644 index 0000000000..a30695058b --- /dev/null +++ b/packages/wallet/src/initialization/index.ts @@ -0,0 +1,14 @@ +export type { + DefaultActions, + DefaultEvents, + DefaultInstances, + DefaultState, + RootMessenger, + WalletDestroyedEvent, +} from './defaults'; +export { initialize } from './initialization'; +export type { + InstanceState, + InitFunctionArguments, + InitializationConfiguration, +} from './types'; diff --git a/packages/wallet/src/initialization/initialization.ts b/packages/wallet/src/initialization/initialization.ts new file mode 100644 index 0000000000..0dd6e303e7 --- /dev/null +++ b/packages/wallet/src/initialization/initialization.ts @@ -0,0 +1,45 @@ +import { WalletOptions } from '../types'; +import type { DefaultInstances } from './defaults'; +import { defaultConfigurations, RootMessenger } from './defaults'; + +export type InitializeArgs = { + options: WalletOptions; + messenger: RootMessenger; +}; + +export function initialize({ + options, + messenger, +}: InitializeArgs): DefaultInstances { + const { state = {}, initializationConfigurations = [] } = options; + + const overriddenConfiguration = initializationConfigurations.map( + (config) => config.name, + ); + + const configurationEntries = initializationConfigurations.concat( + Object.values(defaultConfigurations).filter( + (config) => !overriddenConfiguration.includes(config.name), + ), + ); + + const instances: Record = {}; + + for (const config of configurationEntries) { + const { name } = config; + + const instanceState = state[name]; + + const instanceMessenger = config.messenger(messenger); + + const { instance } = config.init({ + state: instanceState, + messenger: instanceMessenger, + options, + }); + + instances[name] = instance as Record; + } + + return instances as DefaultInstances; +} diff --git a/packages/wallet/src/initialization/types.ts b/packages/wallet/src/initialization/types.ts new file mode 100644 index 0000000000..247fe0bd0b --- /dev/null +++ b/packages/wallet/src/initialization/types.ts @@ -0,0 +1,22 @@ +import type { WalletOptions } from '../types'; +import type { RootMessenger } from './defaults'; + +export type InstanceState = Instance extends { state: unknown } + ? Instance['state'] + : unknown; + +export type InitFunctionArguments = { + state: InstanceState; + messenger: InstanceMessenger; + options: WalletOptions; +}; + +export type InitializationConfiguration = { + name: string; + // This is a method as opposed to function property in order to collect + // heterogeneous InitializationConfiguration values in a single array. + init(args: InitFunctionArguments): { + instance: Instance; + }; + messenger(parent: RootMessenger): InstanceMessenger; +}; diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts new file mode 100644 index 0000000000..c210f114a3 --- /dev/null +++ b/packages/wallet/src/types.ts @@ -0,0 +1,11 @@ +import type { Json } from '@metamask/utils'; + +import type { InitializationConfiguration } from './initialization'; + +export type WalletOptions = { + state?: Record>; + initializationConfigurations?: InitializationConfiguration< + unknown, + unknown + >[]; +}; diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json index 02a0eea03f..931c4d6594 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -5,6 +5,9 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], "include": ["../../types", "./src"] } diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json index 025ba2ef7f..65653ed021 100644 --- a/packages/wallet/tsconfig.json +++ b/packages/wallet/tsconfig.json @@ -3,6 +3,9 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], + "references": [ + { "path": "../base-controller/tsconfig.json" }, + { "path": "../messenger/tsconfig.json" } + ], "include": ["../../types", "./src"] } diff --git a/yarn.lock b/yarn.lock index 46aca8aaa5..4ea888e58a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5881,6 +5881,9 @@ __metadata: resolution: "@metamask/wallet@workspace:packages/wallet" dependencies: "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" From c60a904afe6e483503469e91d298bc5f34fbd8b2 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 18 May 2026 10:56:24 +0200 Subject: [PATCH 02/26] Add KeyringController initialization --- packages/wallet/package.json | 3 + packages/wallet/src/Wallet.test.ts | 104 +++++++++++ .../src/initialization/instances/index.ts | 1 + .../instances/keyring-controller.ts | 162 ++++++++++++++++++ packages/wallet/src/utilities.ts | 26 +++ packages/wallet/tsconfig.build.json | 1 + packages/wallet/tsconfig.json | 1 + yarn.lock | 3 + 8 files changed, 301 insertions(+) create mode 100644 packages/wallet/src/Wallet.test.ts create mode 100644 packages/wallet/src/initialization/instances/index.ts create mode 100644 packages/wallet/src/initialization/instances/keyring-controller.ts create mode 100644 packages/wallet/src/utilities.ts diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 737e401b67..83617f4423 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -54,7 +54,10 @@ }, "dependencies": { "@metamask/base-controller": "^9.1.0", + "@metamask/browser-passworder": "^6.0.0", + "@metamask/keyring-controller": "^25.5.0", "@metamask/messenger": "^1.2.0", + "@metamask/scure-bip39": "^2.1.1", "@metamask/utils": "^11.11.0" }, "devDependencies": { diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts new file mode 100644 index 0000000000..5ce2af4c12 --- /dev/null +++ b/packages/wallet/src/Wallet.test.ts @@ -0,0 +1,104 @@ +import { KeyringController } from '@metamask/keyring-controller'; + +import * as initializationModule from './initialization'; +import { importSecretRecoveryPhrase } from './utilities'; +import { Wallet } from './Wallet'; + +const TEST_SRP = 'test test test test test test test test test test test ball'; +const TEST_PASSWORD = 'testpass'; + +async function setupWallet(): Promise { + const wallet = new Wallet({}); + + await importSecretRecoveryPhrase(wallet, TEST_PASSWORD, TEST_SRP); + + return wallet; +} + +describe('Wallet', () => { + it('exposes state', async () => { + const wallet = await setupWallet(); + const { state } = wallet; + + expect(state.KeyringController).toStrictEqual({ + isUnlocked: true, + keyrings: expect.any(Array), + encryptionKey: expect.any(String), + encryptionSalt: expect.any(String), + vault: expect.any(String), + }); + }); + + describe('lifecycle', () => { + it('exposes controllerMetadata for each initialized controller', async () => { + const wallet = await setupWallet(); + + const names = Object.keys(wallet.controllerMetadata); + expect(names).toStrictEqual(Object.keys(wallet.state)); + for (const name of names) { + expect(wallet.controllerMetadata[name]).toBeDefined(); + } + }); + + it('omits instances without a metadata property from controllerMetadata', async () => { + const fakeMetadata = { + foo: { persist: true, includeInDebugSnapshot: false }, + }; + jest.spyOn(initializationModule, 'initialize').mockReturnValueOnce({ + WithMeta: { state: {}, metadata: fakeMetadata }, + NoMeta: { state: {} }, + } as never); + + const wallet = new Wallet({}); + + expect(wallet.controllerMetadata).toStrictEqual({ + WithMeta: fakeMetadata, + }); + expect(Object.keys(wallet.state)).toStrictEqual(['WithMeta', 'NoMeta']); + }); + + it('publishes Wallet:destroyed exactly once on destroy', async () => { + const wallet = await setupWallet(); + + const listener = jest.fn(); + wallet.messenger.subscribe('Wallet:destroyed', listener); + + await wallet.destroy(); + await wallet.destroy(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('publishes Wallet:destroyed even if a controller destroy throws synchronously', async () => { + const wallet = await setupWallet(); + + jest + .spyOn(KeyringController.prototype, 'destroy') + .mockImplementation(() => { + throw new Error('sync destroy error'); + }); + + const listener = jest.fn(); + wallet.messenger.subscribe('Wallet:destroyed', listener); + + await wallet.destroy(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('publishes Wallet:destroyed even if a controller destroy rejects', async () => { + const wallet = await setupWallet(); + + jest + .spyOn(KeyringController.prototype, 'destroy') + .mockRejectedValue(new Error('async destroy error') as never); + + const listener = jest.fn(); + wallet.messenger.subscribe('Wallet:destroyed', listener); + + await wallet.destroy(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/wallet/src/initialization/instances/index.ts b/packages/wallet/src/initialization/instances/index.ts new file mode 100644 index 0000000000..28a3bf2f23 --- /dev/null +++ b/packages/wallet/src/initialization/instances/index.ts @@ -0,0 +1 @@ +export { keyringController } from './keyring-controller'; diff --git a/packages/wallet/src/initialization/instances/keyring-controller.ts b/packages/wallet/src/initialization/instances/keyring-controller.ts new file mode 100644 index 0000000000..7f3a356864 --- /dev/null +++ b/packages/wallet/src/initialization/instances/keyring-controller.ts @@ -0,0 +1,162 @@ +import type { + DetailedEncryptionResult, + EncryptionKey, + KeyDerivationOptions, +} from '@metamask/browser-passworder'; +import { + encrypt, + encryptWithDetail, + encryptWithKey, + decrypt, + decryptWithDetail, + decryptWithKey, + isVaultUpdated, + keyFromPassword, + importKey, + exportKey, + generateSalt, +} from '@metamask/browser-passworder'; +import type { Encryptor } from '@metamask/keyring-controller'; +import { + KeyringController, + KeyringControllerMessenger, +} from '@metamask/keyring-controller'; +import { Messenger } from '@metamask/messenger'; + +import { InitializationConfiguration } from '../types'; + +/** + * A factory function for the encrypt method of the browser-passworder library, + * that encrypts with a given number of iterations. + * + * @param iterations - The number of iterations to use for the PBKDF2 algorithm. + * @returns A function that encrypts with the given number of iterations. + */ +const encryptFactory = + (iterations: number) => + async ( + password: string, + data: unknown, + key?: EncryptionKey | CryptoKey, + salt?: string, + ): Promise => + encrypt(password, data, key, salt, { + algorithm: 'PBKDF2', + params: { + iterations, + }, + }); + +/** + * A factory function for the encryptWithDetail method of the browser-passworder library, + * that encrypts with a given number of iterations. + * + * @param iterations - The number of iterations to use for the PBKDF2 algorithm. + * @returns A function that encrypts with the given number of iterations. + */ +const encryptWithDetailFactory = + (iterations: number) => + async ( + password: string, + object: unknown, + salt?: string, + ): Promise => + encryptWithDetail(password, object, salt, { + algorithm: 'PBKDF2', + params: { + iterations, + }, + }); + +/** + * A factory function for the keyFromPassword method of the browser-passworder library, + * that generates a key from a password and a salt. + * + * This factory function overrides the default key derivation options with the specified + * number of iterations, unless existing key derivation options are passed in. + * + * @param iterations - The number of iterations to use for the PBKDF2 algorithm. + * @returns A function that generates a key with a potentially overriden number of iterations. + */ +const keyFromPasswordFactory = + (iterations: number) => + async ( + password: string, + salt: string, + exportable?: boolean, + opts?: KeyDerivationOptions, + ): Promise => + keyFromPassword( + password, + salt, + exportable, + opts ?? { + algorithm: 'PBKDF2', + params: { + iterations, + }, + }, + ); + +/** + * A factory function for the isVaultUpdated method of the browser-passworder library, + * that checks if the given vault was encrypted with the given number of iterations. + * + * @param iterations - The number of iterations to use for the PBKDF2 algorithm. + * @returns A function that checks if the vault was encrypted with the given number of iterations. + */ +const isVaultUpdatedFactory = + (iterations: number) => + (vault: string): boolean => + isVaultUpdated(vault, { + algorithm: 'PBKDF2', + params: { + iterations, + }, + }); + +/** + * A factory function that returns an encryptor with the given number of iterations. + * + * The returned encryptor is a wrapper around the browser-passworder library, that + * calls the encrypt and encryptWithDetail methods with the given number of iterations. + * + * @param iterations - The number of iterations to use for the PBKDF2 algorithm. + * @returns An encryptor set with the given number of iterations. + */ +const encryptorFactory = (iterations: number): Encryptor => ({ + encrypt: encryptFactory(iterations), + encryptWithKey, + encryptWithDetail: encryptWithDetailFactory(iterations), + decrypt, + decryptWithKey, + decryptWithDetail, + keyFromPassword: keyFromPasswordFactory(iterations), + isVaultUpdated: isVaultUpdatedFactory(iterations), + importKey, + exportKey, + generateSalt, +}); + +export const keyringController: InitializationConfiguration< + KeyringController, + KeyringControllerMessenger +> = { + name: 'KeyringController', + init: ({ state, messenger }) => { + const instance = new KeyringController({ + state, + messenger, + encryptor: encryptorFactory(600_000), + }); + + return { + instance, + }; + }, + messenger: (parent) => + new Messenger<'KeyringController', never, never, typeof parent>({ + namespace: 'KeyringController', + parent, + }), +}; diff --git a/packages/wallet/src/utilities.ts b/packages/wallet/src/utilities.ts new file mode 100644 index 0000000000..4630dd5460 --- /dev/null +++ b/packages/wallet/src/utilities.ts @@ -0,0 +1,26 @@ +import { wordlist } from '@metamask/scure-bip39/dist/wordlists/english'; + +import { Wallet } from './Wallet'; + +/** + * Import a secret recovery phrase using the wallet object. + * + * @param wallet - The wallet object. + * @param password - The password to the MetaMask wallet (not the SRP). + * @param phrase - The SRP as a string. + */ +export async function importSecretRecoveryPhrase( + wallet: Wallet, + password: string, + phrase: string, +): Promise { + const indices = phrase.split(' ').map((word) => wordlist.indexOf(word)); + const mnemonic = new Uint8Array(new Uint16Array(indices).buffer); + + // TODO: This should use the new MultichainAccountService. + await wallet.messenger.call( + 'KeyringController:createNewVaultAndRestore', + password, + mnemonic, + ); +} diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json index 931c4d6594..b16ce7cfcd 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -7,6 +7,7 @@ }, "references": [ { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json index 65653ed021..1b4b6d0a5f 100644 --- a/packages/wallet/tsconfig.json +++ b/packages/wallet/tsconfig.json @@ -5,6 +5,7 @@ }, "references": [ { "path": "../base-controller/tsconfig.json" }, + { "path": "../keyring-controller/tsconfig.json" }, { "path": "../messenger/tsconfig.json" } ], "include": ["../../types", "./src"] diff --git a/yarn.lock b/yarn.lock index 4ea888e58a..1fed39720b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5882,7 +5882,10 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" + "@metamask/browser-passworder": "npm:^6.0.0" + "@metamask/keyring-controller": "npm:^25.5.0" "@metamask/messenger": "npm:^1.2.0" + "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" From cba84fb50aa292402b8f28a84281c06d4456b1ce Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 18 May 2026 11:19:18 +0200 Subject: [PATCH 03/26] Update README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 0c4c21b1d2..125343e1c9 100644 --- a/README.md +++ b/README.md @@ -565,6 +565,9 @@ linkStyle default opacity:0.5 user_operation_controller --> polling_controller; user_operation_controller --> transaction_controller; user_operation_controller --> eth_block_tracker; + wallet --> base_controller; + wallet --> keyring_controller; + wallet --> messenger; ``` From 6e45a2f4504109be9c578cd370510a790afd2548 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 18 May 2026 11:30:51 +0200 Subject: [PATCH 04/26] Add more tests --- packages/wallet/jest.config.js | 6 +- packages/wallet/src/Wallet.test.ts | 98 +++++++++++++++++++++++------- 2 files changed, 80 insertions(+), 24 deletions(-) diff --git a/packages/wallet/jest.config.js b/packages/wallet/jest.config.js index ca08413339..6412fb6b43 100644 --- a/packages/wallet/jest.config.js +++ b/packages/wallet/jest.config.js @@ -18,9 +18,9 @@ module.exports = merge(baseConfig, { coverageThreshold: { global: { branches: 100, - functions: 100, - lines: 100, - statements: 100, + functions: 88, + lines: 95.52, + statements: 95.65, }, }, }); diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 5ce2af4c12..5e16df005d 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -1,4 +1,6 @@ import { KeyringController } from '@metamask/keyring-controller'; +import { Messenger } from '@metamask/messenger'; +import { Json } from '@metamask/utils'; import * as initializationModule from './initialization'; import { importSecretRecoveryPhrase } from './utilities'; @@ -29,34 +31,64 @@ describe('Wallet', () => { }); }); - describe('lifecycle', () => { - it('exposes controllerMetadata for each initialized controller', async () => { - const wallet = await setupWallet(); + it('supports passing additional initialization configurations', async () => { + class DummyController { + state = { foo: 'bar' }; + } + + class DummyService {} + + const wallet = new Wallet({ + initializationConfigurations: [ + { + name: 'KeyringController', + messenger: () => new Messenger({ namespace: 'KeyringController' }), + init: () => ({ instance: new DummyController() }), + }, + { + name: 'TestService', + messenger: () => new Messenger({ namespace: 'TestService' }), + init: () => ({ instance: new DummyService() }), + }, + ], + }); + const { state } = wallet; - const names = Object.keys(wallet.controllerMetadata); - expect(names).toStrictEqual(Object.keys(wallet.state)); - for (const name of names) { - expect(wallet.controllerMetadata[name]).toBeDefined(); - } + expect(state.KeyringController).toStrictEqual({ + foo: 'bar', }); - it('omits instances without a metadata property from controllerMetadata', async () => { - const fakeMetadata = { - foo: { persist: true, includeInDebugSnapshot: false }, - }; - jest.spyOn(initializationModule, 'initialize').mockReturnValueOnce({ - WithMeta: { state: {}, metadata: fakeMetadata }, - NoMeta: { state: {} }, - } as never); + expect((state as Record)['TestService']).toBeNull(); + }); - const wallet = new Wallet({}); + it('exposes controllerMetadata for each initialized controller', async () => { + const wallet = await setupWallet(); - expect(wallet.controllerMetadata).toStrictEqual({ - WithMeta: fakeMetadata, - }); - expect(Object.keys(wallet.state)).toStrictEqual(['WithMeta', 'NoMeta']); + const names = Object.keys(wallet.controllerMetadata); + expect(names).toStrictEqual(Object.keys(wallet.state)); + for (const name of names) { + expect(wallet.controllerMetadata[name]).toBeDefined(); + } + }); + + it('omits instances without a metadata property from controllerMetadata', async () => { + const fakeMetadata = { + foo: { persist: true, includeInDebugSnapshot: false }, + }; + jest.spyOn(initializationModule, 'initialize').mockReturnValueOnce({ + WithMeta: { state: {}, metadata: fakeMetadata }, + NoMeta: { state: {} }, + } as never); + + const wallet = new Wallet({}); + + expect(wallet.controllerMetadata).toStrictEqual({ + WithMeta: fakeMetadata, }); + expect(Object.keys(wallet.state)).toStrictEqual(['WithMeta', 'NoMeta']); + }); + describe('lifecycle', () => { it('publishes Wallet:destroyed exactly once on destroy', async () => { const wallet = await setupWallet(); @@ -101,4 +133,28 @@ describe('Wallet', () => { expect(listener).toHaveBeenCalledTimes(1); }); }); + + describe('KeyringController', () => { + it('can unlock and populate accounts', async () => { + const wallet = await setupWallet(); + const { messenger } = wallet; + + expect( + await messenger.call('KeyringController:getAccounts'), + ).toStrictEqual(['0xc6d5a3c98ec9073b54fa0969957bd582e8d874bf']); + }); + + it('can lock', async () => { + const wallet = await setupWallet(); + const { messenger } = wallet; + + expect(await messenger.call('KeyringController:setLocked')); + + expect(wallet.state.KeyringController).toStrictEqual({ + isUnlocked: false, + keyrings: [], + vault: expect.any(String), + }); + }); + }); }); From b4bf73c2727776ba4d2994c080c50bc8ea6c13bb Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 18 May 2026 11:34:06 +0200 Subject: [PATCH 05/26] Fix Yarn constraints --- packages/wallet/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 83617f4423..1018f53f04 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -58,7 +58,7 @@ "@metamask/keyring-controller": "^25.5.0", "@metamask/messenger": "^1.2.0", "@metamask/scure-bip39": "^2.1.1", - "@metamask/utils": "^11.11.0" + "@metamask/utils": "^11.9.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/yarn.lock b/yarn.lock index 1fed39720b..dce0f91aaf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5886,7 +5886,7 @@ __metadata: "@metamask/keyring-controller": "npm:^25.5.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/scure-bip39": "npm:^2.1.1" - "@metamask/utils": "npm:^11.11.0" + "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" From 9a2bb50e81200438db7ac0dacaf67d3991ceecc1 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 18 May 2026 12:09:06 +0200 Subject: [PATCH 06/26] Add instance specific options --- packages/wallet/src/Wallet.test.ts | 23 +++++++++++++++++++ packages/wallet/src/Wallet.ts | 2 +- .../src/initialization/initialization.ts | 5 ++-- .../instances/keyring-controller.ts | 5 ++-- packages/wallet/src/initialization/types.ts | 17 +++++++++++--- packages/wallet/src/types.ts | 8 +++++++ 6 files changed, 52 insertions(+), 8 deletions(-) diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 5e16df005d..e1d8ced1e0 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -2,6 +2,7 @@ import { KeyringController } from '@metamask/keyring-controller'; import { Messenger } from '@metamask/messenger'; import { Json } from '@metamask/utils'; +import MockEncryptor from '../../keyring-controller/tests/mocks/mockEncryptor'; import * as initializationModule from './initialization'; import { importSecretRecoveryPhrase } from './utilities'; import { Wallet } from './Wallet'; @@ -31,6 +32,28 @@ describe('Wallet', () => { }); }); + it('supports passing instance options', async () => { + const wallet = new Wallet({ + instanceOptions: { + KeyringController: { + encryptor: new MockEncryptor(), + }, + }, + }); + + await importSecretRecoveryPhrase(wallet, TEST_PASSWORD, TEST_SRP); + + const { state } = wallet; + + const vault = JSON.parse(state.KeyringController.vault as string); + + expect(vault).toStrictEqual({ + data: expect.any(String), + iv: 'iv', + salt: 'salt', + }); + }); + it('supports passing additional initialization configurations', async () => { class DummyController { state = { foo: 'bar' }; diff --git a/packages/wallet/src/Wallet.ts b/packages/wallet/src/Wallet.ts index d1d893bcf2..63688215ad 100644 --- a/packages/wallet/src/Wallet.ts +++ b/packages/wallet/src/Wallet.ts @@ -13,7 +13,7 @@ import { initialize } from './initialization'; import { WalletOptions } from './types'; export class Wallet { - // TODO: Expand types when passing additionalConfigurations. + // TODO: Expand default types when passing additionalConfigurations. readonly #messenger: RootMessenger; readonly #instances: DefaultInstances; diff --git a/packages/wallet/src/initialization/initialization.ts b/packages/wallet/src/initialization/initialization.ts index 0dd6e303e7..abe8bd4b06 100644 --- a/packages/wallet/src/initialization/initialization.ts +++ b/packages/wallet/src/initialization/initialization.ts @@ -1,4 +1,4 @@ -import { WalletOptions } from '../types'; +import type { InstanceSpecificOptions, WalletOptions } from '../types'; import type { DefaultInstances } from './defaults'; import { defaultConfigurations, RootMessenger } from './defaults'; @@ -35,7 +35,8 @@ export function initialize({ const { instance } = config.init({ state: instanceState, messenger: instanceMessenger, - options, + options: + options.instanceOptions?.[name as keyof InstanceSpecificOptions] ?? {}, }); instances[name] = instance as Record; diff --git a/packages/wallet/src/initialization/instances/keyring-controller.ts b/packages/wallet/src/initialization/instances/keyring-controller.ts index 7f3a356864..22d1774320 100644 --- a/packages/wallet/src/initialization/instances/keyring-controller.ts +++ b/packages/wallet/src/initialization/instances/keyring-controller.ts @@ -143,11 +143,12 @@ export const keyringController: InitializationConfiguration< KeyringControllerMessenger > = { name: 'KeyringController', - init: ({ state, messenger }) => { + init: ({ state, messenger, options }) => { const instance = new KeyringController({ state, messenger, - encryptor: encryptorFactory(600_000), + keyringBuilders: options.keyringBuilders, + encryptor: options.encryptor ?? encryptorFactory(600_000), }); return { diff --git a/packages/wallet/src/initialization/types.ts b/packages/wallet/src/initialization/types.ts index 247fe0bd0b..a016a6fdc1 100644 --- a/packages/wallet/src/initialization/types.ts +++ b/packages/wallet/src/initialization/types.ts @@ -1,18 +1,29 @@ -import type { WalletOptions } from '../types'; +import type { InstanceSpecificOptions } from '../types'; import type { RootMessenger } from './defaults'; export type InstanceState = Instance extends { state: unknown } ? Instance['state'] : unknown; +type InstanceName = Instance extends { + name: infer Name extends string; +} + ? Name + : string; + +type InstanceOptions = + InstanceName extends keyof InstanceSpecificOptions + ? NonNullable]> + : unknown; + export type InitFunctionArguments = { state: InstanceState; messenger: InstanceMessenger; - options: WalletOptions; + options: InstanceOptions; }; export type InitializationConfiguration = { - name: string; + name: InstanceName; // This is a method as opposed to function property in order to collect // heterogeneous InitializationConfiguration values in a single array. init(args: InitFunctionArguments): { diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index c210f114a3..e9ef63cca5 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -1,3 +1,4 @@ +import { KeyringControllerOptions } from '@metamask/keyring-controller'; import type { Json } from '@metamask/utils'; import type { InitializationConfiguration } from './initialization'; @@ -8,4 +9,11 @@ export type WalletOptions = { unknown, unknown >[]; + instanceOptions?: InstanceSpecificOptions; +}; + +export type InstanceSpecificOptions = { + KeyringController?: Partial< + Pick + >; }; From 90b4ca73b0ee1d371e35af5a2465b7ba58f5d72b Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 18 May 2026 12:13:26 +0200 Subject: [PATCH 07/26] Return instances directly --- packages/wallet/jest.config.js | 4 ++-- packages/wallet/src/Wallet.test.ts | 4 ++-- packages/wallet/src/initialization/initialization.ts | 2 +- .../initialization/instances/keyring-controller.ts | 11 +++-------- packages/wallet/src/initialization/types.ts | 6 +----- 5 files changed, 9 insertions(+), 18 deletions(-) diff --git a/packages/wallet/jest.config.js b/packages/wallet/jest.config.js index 6412fb6b43..046f07de60 100644 --- a/packages/wallet/jest.config.js +++ b/packages/wallet/jest.config.js @@ -19,8 +19,8 @@ module.exports = merge(baseConfig, { global: { branches: 100, functions: 88, - lines: 95.52, - statements: 95.65, + lines: 95.45, + statements: 95.58, }, }, }); diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index e1d8ced1e0..fedadc4c35 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -66,12 +66,12 @@ describe('Wallet', () => { { name: 'KeyringController', messenger: () => new Messenger({ namespace: 'KeyringController' }), - init: () => ({ instance: new DummyController() }), + init: () => new DummyController(), }, { name: 'TestService', messenger: () => new Messenger({ namespace: 'TestService' }), - init: () => ({ instance: new DummyService() }), + init: () => new DummyService(), }, ], }); diff --git a/packages/wallet/src/initialization/initialization.ts b/packages/wallet/src/initialization/initialization.ts index abe8bd4b06..ce2a0eaba7 100644 --- a/packages/wallet/src/initialization/initialization.ts +++ b/packages/wallet/src/initialization/initialization.ts @@ -32,7 +32,7 @@ export function initialize({ const instanceMessenger = config.messenger(messenger); - const { instance } = config.init({ + const instance = config.init({ state: instanceState, messenger: instanceMessenger, options: diff --git a/packages/wallet/src/initialization/instances/keyring-controller.ts b/packages/wallet/src/initialization/instances/keyring-controller.ts index 22d1774320..3ca7aaf597 100644 --- a/packages/wallet/src/initialization/instances/keyring-controller.ts +++ b/packages/wallet/src/initialization/instances/keyring-controller.ts @@ -143,18 +143,13 @@ export const keyringController: InitializationConfiguration< KeyringControllerMessenger > = { name: 'KeyringController', - init: ({ state, messenger, options }) => { - const instance = new KeyringController({ + init: ({ state, messenger, options }) => + new KeyringController({ state, messenger, keyringBuilders: options.keyringBuilders, encryptor: options.encryptor ?? encryptorFactory(600_000), - }); - - return { - instance, - }; - }, + }), messenger: (parent) => new Messenger<'KeyringController', never, never, typeof parent>({ namespace: 'KeyringController', diff --git a/packages/wallet/src/initialization/types.ts b/packages/wallet/src/initialization/types.ts index a016a6fdc1..aac53a8a9e 100644 --- a/packages/wallet/src/initialization/types.ts +++ b/packages/wallet/src/initialization/types.ts @@ -24,10 +24,6 @@ export type InitFunctionArguments = { export type InitializationConfiguration = { name: InstanceName; - // This is a method as opposed to function property in order to collect - // heterogeneous InitializationConfiguration values in a single array. - init(args: InitFunctionArguments): { - instance: Instance; - }; + init(args: InitFunctionArguments): Instance; messenger(parent: RootMessenger): InstanceMessenger; }; From 77a0013784d6f23d05101739319736f6e8a385ee Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 18 May 2026 12:16:00 +0200 Subject: [PATCH 08/26] Add CHANGELOG --- packages/wallet/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index b518709c7b..889a107355 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Initial release ([#8838](https://github.com/MetaMask/core/pull/8838)) + [Unreleased]: https://github.com/MetaMask/core/ From 465ed33b913c84dec364d93c035b2cb105dc02c0 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 18 May 2026 12:57:11 +0200 Subject: [PATCH 09/26] Fix tests and lint --- packages/wallet/src/Wallet.test.ts | 21 +++++++++++++++------ packages/wallet/src/Wallet.ts | 1 + 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index fedadc4c35..4977fc8e5f 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -1,6 +1,7 @@ import { KeyringController } from '@metamask/keyring-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'; @@ -19,6 +20,12 @@ async function setupWallet(): Promise { } describe('Wallet', () => { + 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; + }); + it('exposes state', async () => { const wallet = await setupWallet(); const { state } = wallet; @@ -65,13 +72,15 @@ describe('Wallet', () => { initializationConfigurations: [ { name: 'KeyringController', - messenger: () => new Messenger({ namespace: 'KeyringController' }), - init: () => new DummyController(), + messenger: (): Messenger => + new Messenger({ namespace: 'KeyringController' }), + init: (): DummyController => new DummyController(), }, { name: 'TestService', - messenger: () => new Messenger({ namespace: 'TestService' }), - init: () => new DummyService(), + messenger: (): Messenger => + new Messenger({ namespace: 'TestService' }), + init: (): DummyService => new DummyService(), }, ], }); @@ -81,7 +90,7 @@ describe('Wallet', () => { foo: 'bar', }); - expect((state as Record)['TestService']).toBeNull(); + expect((state as Record).TestService).toBeNull(); }); it('exposes controllerMetadata for each initialized controller', async () => { @@ -171,7 +180,7 @@ describe('Wallet', () => { const wallet = await setupWallet(); const { messenger } = wallet; - expect(await messenger.call('KeyringController:setLocked')); + await messenger.call('KeyringController:setLocked'); expect(wallet.state.KeyringController).toStrictEqual({ isUnlocked: false, diff --git a/packages/wallet/src/Wallet.ts b/packages/wallet/src/Wallet.ts index 63688215ad..9f45599fc9 100644 --- a/packages/wallet/src/Wallet.ts +++ b/packages/wallet/src/Wallet.ts @@ -73,6 +73,7 @@ export class Wallet { // @ts-expect-error Accessing protected property. if (typeof instance.destroy === 'function') { // @ts-expect-error Accessing protected property. + // eslint-disable-next-line @typescript-eslint/await-thenable return await instance.destroy(); } /* istanbul ignore next */ From 04e70f2ecf6791da52eab79e085ba20982e1ac80 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 18 May 2026 13:04:49 +0200 Subject: [PATCH 10/26] Fix Node 18 tests --- packages/wallet/src/Wallet.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 4977fc8e5f..a0d0e09494 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -24,6 +24,13 @@ describe('Wallet', () => { // 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('exposes state', async () => { From 0f91628f491f58a338eb52404bb710a94423d6e7 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 18 May 2026 15:05:27 +0200 Subject: [PATCH 11/26] Expose instances for now --- packages/wallet/src/Wallet.test.ts | 12 ++++++++++++ packages/wallet/src/Wallet.ts | 11 +++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index a0d0e09494..6a0303006b 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -46,6 +46,18 @@ describe('Wallet', () => { }); }); + it('exposes instances', async () => { + const wallet = await setupWallet(); + + expect(wallet.getInstance('KeyringController').state).toStrictEqual({ + isUnlocked: true, + keyrings: expect.any(Array), + encryptionKey: expect.any(String), + encryptionSalt: expect.any(String), + vault: expect.any(String), + }); + }); + it('supports passing instance options', async () => { const wallet = new Wallet({ instanceOptions: { diff --git a/packages/wallet/src/Wallet.ts b/packages/wallet/src/Wallet.ts index 9f45599fc9..70acdf79f3 100644 --- a/packages/wallet/src/Wallet.ts +++ b/packages/wallet/src/Wallet.ts @@ -61,6 +61,17 @@ export class Wallet { return this.#controllerMetadata; } + /** + * Get an instantiated controller or service. + * + * @param name - The name. + * @returns - The instance. + * @deprecated - Please use the messenger instead of direct access. + */ + getInstance(name: keyof DefaultInstances) { + return this.#instances[name]; + } + async destroy(): Promise { if (this.#destroyed) { return; From 48fb182485fb4bc453fac929b33a98d6a802928a Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 19 May 2026 10:17:48 +0200 Subject: [PATCH 12/26] Allow passing messenger --- packages/wallet/src/Wallet.ts | 18 ++++++++++-------- packages/wallet/src/types.ts | 8 +++++++- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/wallet/src/Wallet.ts b/packages/wallet/src/Wallet.ts index 70acdf79f3..1eb723b25a 100644 --- a/packages/wallet/src/Wallet.ts +++ b/packages/wallet/src/Wallet.ts @@ -22,12 +22,14 @@ export class Wallet { Record> >; - #destroyed = false; + #isDestroyed = false; constructor(options: WalletOptions) { - this.#messenger = new Messenger({ - namespace: 'Wallet', - }); + this.#messenger = + options.messenger ?? + new Messenger({ + namespace: 'Wallet', + }); this.#instances = initialize({ options, @@ -63,9 +65,9 @@ export class Wallet { /** * Get an instantiated controller or service. - * + * * @param name - The name. - * @returns - The instance. + * @returns The instance. * @deprecated - Please use the messenger instead of direct access. */ getInstance(name: keyof DefaultInstances) { @@ -73,11 +75,11 @@ export class Wallet { } async destroy(): Promise { - if (this.#destroyed) { + if (this.#isDestroyed) { return; } - this.#destroyed = true; + this.#isDestroyed = true; await Promise.allSettled( Object.values(this.#instances).map(async (instance) => { diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index e9ef63cca5..406f16d74d 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -1,9 +1,15 @@ import { KeyringControllerOptions } from '@metamask/keyring-controller'; import type { Json } from '@metamask/utils'; -import type { InitializationConfiguration } from './initialization'; +import type { + DefaultActions, + DefaultEvents, + InitializationConfiguration, + RootMessenger, +} from './initialization'; export type WalletOptions = { + messenger?: RootMessenger; state?: Record>; initializationConfigurations?: InitializationConfiguration< unknown, From 0fd9d7fffad371ae13d90def9fd83034a3f5e394 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 19 May 2026 10:18:01 +0200 Subject: [PATCH 13/26] Address a couple comments --- packages/wallet/src/initialization/defaults.ts | 4 ++-- packages/wallet/src/initialization/initialization.ts | 4 ++-- .../wallet/src/initialization/instances/keyring-controller.ts | 2 +- packages/wallet/src/initialization/types.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/wallet/src/initialization/defaults.ts b/packages/wallet/src/initialization/defaults.ts index 7156aad7a0..f1eb7a4fb5 100644 --- a/packages/wallet/src/initialization/defaults.ts +++ b/packages/wallet/src/initialization/defaults.ts @@ -48,8 +48,8 @@ export type DefaultEvents = | WalletDestroyedEvent; export type RootMessenger< - AllowedActions extends ActionConstraint = ActionConstraint, - AllowedEvents extends EventConstraint = EventConstraint, + AllowedActions extends ActionConstraint, + AllowedEvents extends EventConstraint, > = Messenger<'Wallet', AllowedActions, AllowedEvents>; export type DefaultState = { diff --git a/packages/wallet/src/initialization/initialization.ts b/packages/wallet/src/initialization/initialization.ts index ce2a0eaba7..3572e83f6f 100644 --- a/packages/wallet/src/initialization/initialization.ts +++ b/packages/wallet/src/initialization/initialization.ts @@ -1,10 +1,10 @@ import type { InstanceSpecificOptions, WalletOptions } from '../types'; -import type { DefaultInstances } from './defaults'; +import type { DefaultActions, DefaultEvents, DefaultInstances } from './defaults'; import { defaultConfigurations, RootMessenger } from './defaults'; export type InitializeArgs = { options: WalletOptions; - messenger: RootMessenger; + messenger: RootMessenger; }; export function initialize({ diff --git a/packages/wallet/src/initialization/instances/keyring-controller.ts b/packages/wallet/src/initialization/instances/keyring-controller.ts index 3ca7aaf597..d0721b283a 100644 --- a/packages/wallet/src/initialization/instances/keyring-controller.ts +++ b/packages/wallet/src/initialization/instances/keyring-controller.ts @@ -151,7 +151,7 @@ export const keyringController: InitializationConfiguration< encryptor: options.encryptor ?? encryptorFactory(600_000), }), messenger: (parent) => - new Messenger<'KeyringController', never, never, typeof parent>({ + new Messenger({ namespace: 'KeyringController', parent, }), diff --git a/packages/wallet/src/initialization/types.ts b/packages/wallet/src/initialization/types.ts index aac53a8a9e..e0515e5569 100644 --- a/packages/wallet/src/initialization/types.ts +++ b/packages/wallet/src/initialization/types.ts @@ -1,5 +1,5 @@ import type { InstanceSpecificOptions } from '../types'; -import type { RootMessenger } from './defaults'; +import type { DefaultActions, DefaultEvents, RootMessenger } from './defaults'; export type InstanceState = Instance extends { state: unknown } ? Instance['state'] @@ -25,5 +25,5 @@ export type InitFunctionArguments = { export type InitializationConfiguration = { name: InstanceName; init(args: InitFunctionArguments): Instance; - messenger(parent: RootMessenger): InstanceMessenger; + messenger(parent: RootMessenger): InstanceMessenger; }; From d706aa285d33fc068f968264318f26159f5e1e50 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 20 May 2026 10:29:32 +0200 Subject: [PATCH 14/26] Address more PR comments --- packages/wallet/src/Wallet.test.ts | 6 +++--- packages/wallet/src/Wallet.ts | 12 +++++++----- packages/wallet/src/index.ts | 2 +- packages/wallet/src/initialization/index.ts | 14 -------------- .../wallet/src/initialization/initialization.ts | 8 ++++++-- .../initialization/instances/keyring-controller.ts | 2 +- packages/wallet/src/initialization/types.ts | 4 +++- packages/wallet/src/types.ts | 4 ++-- 8 files changed, 23 insertions(+), 29 deletions(-) delete mode 100644 packages/wallet/src/initialization/index.ts diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 6a0303006b..6bfe98d53a 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -49,7 +49,7 @@ describe('Wallet', () => { it('exposes instances', async () => { const wallet = await setupWallet(); - expect(wallet.getInstance('KeyringController').state).toStrictEqual({ + expect(wallet.getInstance('KeyringController')?.state).toStrictEqual({ isUnlocked: true, keyrings: expect.any(Array), encryptionKey: expect.any(String), @@ -91,13 +91,13 @@ describe('Wallet', () => { initializationConfigurations: [ { name: 'KeyringController', - messenger: (): Messenger => + getMessenger: (): Messenger => new Messenger({ namespace: 'KeyringController' }), init: (): DummyController => new DummyController(), }, { name: 'TestService', - messenger: (): Messenger => + getMessenger: (): Messenger => new Messenger({ namespace: 'TestService' }), init: (): DummyService => new DummyService(), }, diff --git a/packages/wallet/src/Wallet.ts b/packages/wallet/src/Wallet.ts index 1eb723b25a..b6e22907a7 100644 --- a/packages/wallet/src/Wallet.ts +++ b/packages/wallet/src/Wallet.ts @@ -8,8 +8,8 @@ import type { DefaultInstances, DefaultState, RootMessenger, -} from './initialization'; -import { initialize } from './initialization'; +} from './initialization/defaults'; +import { initialize } from './initialization/initialization'; import { WalletOptions } from './types'; export class Wallet { @@ -67,11 +67,13 @@ export class Wallet { * Get an instantiated controller or service. * * @param name - The name. - * @returns The instance. + * @returns The instance, if it exists. * @deprecated - Please use the messenger instead of direct access. */ - getInstance(name: keyof DefaultInstances) { - return this.#instances[name]; + getInstance( + name: string, + ): DefaultInstances[keyof DefaultInstances] | undefined { + return this.#instances[name as keyof DefaultInstances]; } async destroy(): Promise { diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index 5fa0502ec2..129da70bdf 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -5,4 +5,4 @@ export type { DefaultEvents, RootMessenger, WalletDestroyedEvent, -} from './initialization'; +} from './initialization/defaults'; diff --git a/packages/wallet/src/initialization/index.ts b/packages/wallet/src/initialization/index.ts deleted file mode 100644 index a30695058b..0000000000 --- a/packages/wallet/src/initialization/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type { - DefaultActions, - DefaultEvents, - DefaultInstances, - DefaultState, - RootMessenger, - WalletDestroyedEvent, -} from './defaults'; -export { initialize } from './initialization'; -export type { - InstanceState, - InitFunctionArguments, - InitializationConfiguration, -} from './types'; diff --git a/packages/wallet/src/initialization/initialization.ts b/packages/wallet/src/initialization/initialization.ts index 3572e83f6f..1f88f2fd38 100644 --- a/packages/wallet/src/initialization/initialization.ts +++ b/packages/wallet/src/initialization/initialization.ts @@ -1,5 +1,9 @@ import type { InstanceSpecificOptions, WalletOptions } from '../types'; -import type { DefaultActions, DefaultEvents, DefaultInstances } from './defaults'; +import type { + DefaultActions, + DefaultEvents, + DefaultInstances, +} from './defaults'; import { defaultConfigurations, RootMessenger } from './defaults'; export type InitializeArgs = { @@ -30,7 +34,7 @@ export function initialize({ const instanceState = state[name]; - const instanceMessenger = config.messenger(messenger); + const instanceMessenger = config.getMessenger(messenger); const instance = config.init({ state: instanceState, diff --git a/packages/wallet/src/initialization/instances/keyring-controller.ts b/packages/wallet/src/initialization/instances/keyring-controller.ts index d0721b283a..d5c8be38d3 100644 --- a/packages/wallet/src/initialization/instances/keyring-controller.ts +++ b/packages/wallet/src/initialization/instances/keyring-controller.ts @@ -150,7 +150,7 @@ export const keyringController: InitializationConfiguration< keyringBuilders: options.keyringBuilders, encryptor: options.encryptor ?? encryptorFactory(600_000), }), - messenger: (parent) => + getMessenger: (parent) => new Messenger({ namespace: 'KeyringController', parent, diff --git a/packages/wallet/src/initialization/types.ts b/packages/wallet/src/initialization/types.ts index e0515e5569..8e052d9757 100644 --- a/packages/wallet/src/initialization/types.ts +++ b/packages/wallet/src/initialization/types.ts @@ -25,5 +25,7 @@ export type InitFunctionArguments = { export type InitializationConfiguration = { name: InstanceName; init(args: InitFunctionArguments): Instance; - messenger(parent: RootMessenger): InstanceMessenger; + getMessenger( + parent: RootMessenger, + ): InstanceMessenger; }; diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index 406f16d74d..ae9c1d3843 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -4,9 +4,9 @@ import type { Json } from '@metamask/utils'; import type { DefaultActions, DefaultEvents, - InitializationConfiguration, RootMessenger, -} from './initialization'; +} from './initialization/defaults'; +import { InitializationConfiguration } from './initialization/types'; export type WalletOptions = { messenger?: RootMessenger; From 7eb54d2425870f5eeb28b2faec41c6fb24bb8797 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 20 May 2026 12:03:53 +0200 Subject: [PATCH 15/26] Fix import --- packages/wallet/src/Wallet.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 6bfe98d53a..ba391e1175 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -4,7 +4,7 @@ import { Json } from '@metamask/utils'; import { webcrypto } from 'crypto'; import MockEncryptor from '../../keyring-controller/tests/mocks/mockEncryptor'; -import * as initializationModule from './initialization'; +import * as initializationModule from './initialization/initialization'; import { importSecretRecoveryPhrase } from './utilities'; import { Wallet } from './Wallet'; From 8b2d36494edddff0178e1968695b7edadf387241 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 20 May 2026 15:01:26 +0200 Subject: [PATCH 16/26] Tweak state types --- packages/wallet/src/initialization/initialization.ts | 3 ++- packages/wallet/src/initialization/types.ts | 4 ++-- packages/wallet/src/types.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/wallet/src/initialization/initialization.ts b/packages/wallet/src/initialization/initialization.ts index 1f88f2fd38..9ecc98a6c2 100644 --- a/packages/wallet/src/initialization/initialization.ts +++ b/packages/wallet/src/initialization/initialization.ts @@ -37,7 +37,8 @@ export function initialize({ const instanceMessenger = config.getMessenger(messenger); const instance = config.init({ - state: instanceState, + // TODO: Consider whether this can be improved + state: instanceState as never, messenger: instanceMessenger, options: options.instanceOptions?.[name as keyof InstanceSpecificOptions] ?? {}, diff --git a/packages/wallet/src/initialization/types.ts b/packages/wallet/src/initialization/types.ts index 8e052d9757..09bcb0c451 100644 --- a/packages/wallet/src/initialization/types.ts +++ b/packages/wallet/src/initialization/types.ts @@ -3,7 +3,7 @@ import type { DefaultActions, DefaultEvents, RootMessenger } from './defaults'; export type InstanceState = Instance extends { state: unknown } ? Instance['state'] - : unknown; + : undefined; type InstanceName = Instance extends { name: infer Name extends string; @@ -17,7 +17,7 @@ type InstanceOptions = : unknown; export type InitFunctionArguments = { - state: InstanceState; + state: InstanceState | undefined; messenger: InstanceMessenger; options: InstanceOptions; }; diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index ae9c1d3843..8dd338ef15 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -10,7 +10,7 @@ import { InitializationConfiguration } from './initialization/types'; export type WalletOptions = { messenger?: RootMessenger; - state?: Record>; + state?: Record | undefined>; initializationConfigurations?: InitializationConfiguration< unknown, unknown From 23ef6e2cd57f7998988a3ea38d99934bbc023f6b Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 20 May 2026 16:49:58 +0200 Subject: [PATCH 17/26] Export DefaultInstances --- packages/wallet/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index 129da70bdf..24fa6898ad 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -3,6 +3,7 @@ export type { WalletOptions } from './types'; export type { DefaultActions, DefaultEvents, + DefaultInstances, RootMessenger, WalletDestroyedEvent, } from './initialization/defaults'; From 0b891384f98cc7287e462e4b886292adde1c2b93 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 20 May 2026 16:59:34 +0200 Subject: [PATCH 18/26] Add overloads for getInstance --- packages/wallet/src/Wallet.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/wallet/src/Wallet.ts b/packages/wallet/src/Wallet.ts index b6e22907a7..a22ef43641 100644 --- a/packages/wallet/src/Wallet.ts +++ b/packages/wallet/src/Wallet.ts @@ -63,6 +63,14 @@ export class Wallet { return this.#controllerMetadata; } + getInstance( + name: Name, + ): DefaultInstances[Name]; + + getInstance( + name: string, + ): DefaultInstances[keyof DefaultInstances] | undefined; + /** * Get an instantiated controller or service. * From 285edb4f08a2715ada71ec1e7fdf1d592b5dae8c Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 21 May 2026 10:26:24 +0200 Subject: [PATCH 19/26] Use camel case for instanceOptions --- packages/wallet/src/Wallet.test.ts | 4 ++-- .../wallet/src/initialization/initialization.ts | 6 ++++-- packages/wallet/src/initialization/types.ts | 13 +++++++++++-- packages/wallet/src/types.ts | 2 +- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index ba391e1175..3ec1cc1511 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -49,7 +49,7 @@ describe('Wallet', () => { it('exposes instances', async () => { const wallet = await setupWallet(); - expect(wallet.getInstance('KeyringController')?.state).toStrictEqual({ + expect(wallet.getInstance('KeyringController').state).toStrictEqual({ isUnlocked: true, keyrings: expect.any(Array), encryptionKey: expect.any(String), @@ -61,7 +61,7 @@ describe('Wallet', () => { it('supports passing instance options', async () => { const wallet = new Wallet({ instanceOptions: { - KeyringController: { + keyringController: { encryptor: new MockEncryptor(), }, }, diff --git a/packages/wallet/src/initialization/initialization.ts b/packages/wallet/src/initialization/initialization.ts index 9ecc98a6c2..ea00796af4 100644 --- a/packages/wallet/src/initialization/initialization.ts +++ b/packages/wallet/src/initialization/initialization.ts @@ -36,12 +36,14 @@ export function initialize({ const instanceMessenger = config.getMessenger(messenger); + const camelCaseName = + `${name.charAt(0).toLowerCase()}${name.slice(1)}` as keyof InstanceSpecificOptions; + const instance = config.init({ // TODO: Consider whether this can be improved state: instanceState as never, messenger: instanceMessenger, - options: - options.instanceOptions?.[name as keyof InstanceSpecificOptions] ?? {}, + options: options.instanceOptions?.[camelCaseName] ?? {}, }); instances[name] = instance as Record; diff --git a/packages/wallet/src/initialization/types.ts b/packages/wallet/src/initialization/types.ts index 09bcb0c451..ad36a2c6d8 100644 --- a/packages/wallet/src/initialization/types.ts +++ b/packages/wallet/src/initialization/types.ts @@ -11,9 +11,18 @@ type InstanceName = Instance extends { ? Name : string; +type LowerCaseFirstLetter = + Name extends `${infer Character1}${infer Rest}` + ? `${Lowercase}${Rest}` + : Lowercase; + +type CamelCaseInstanceName = LowerCaseFirstLetter< + InstanceName +>; + type InstanceOptions = - InstanceName extends keyof InstanceSpecificOptions - ? NonNullable]> + CamelCaseInstanceName extends keyof InstanceSpecificOptions + ? NonNullable]> : unknown; export type InitFunctionArguments = { diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index 8dd338ef15..6cea037ad8 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -19,7 +19,7 @@ export type WalletOptions = { }; export type InstanceSpecificOptions = { - KeyringController?: Partial< + keyringController?: Partial< Pick >; }; From 2bdac5438037b6105d991947b3bb7a7cf7b64b0a Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 21 May 2026 10:57:13 +0200 Subject: [PATCH 20/26] Tweak encryptor types --- .../instances/keyring-controller.ts | 15 ++++++++++++++- packages/wallet/src/types.ts | 8 +++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/wallet/src/initialization/instances/keyring-controller.ts b/packages/wallet/src/initialization/instances/keyring-controller.ts index d5c8be38d3..b311fced30 100644 --- a/packages/wallet/src/initialization/instances/keyring-controller.ts +++ b/packages/wallet/src/initialization/instances/keyring-controller.ts @@ -138,6 +138,17 @@ const encryptorFactory = (iterations: number): Encryptor => ({ generateSalt, }); +type MobileEncryptionKey = { + key: string; + lib: string; + exportable: boolean; + keyMetadata: KeyDerivationOptions; +}; + +export type GenericEncryptor = + | Encryptor + | Encryptor; + export const keyringController: InitializationConfiguration< KeyringController, KeyringControllerMessenger @@ -148,7 +159,9 @@ export const keyringController: InitializationConfiguration< state, messenger, keyringBuilders: options.keyringBuilders, - encryptor: options.encryptor ?? encryptorFactory(600_000), + encryptor: + options.encryptor ?? + (encryptorFactory(600_000) as Encryptor), }), getMessenger: (parent) => new Messenger({ diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index 6cea037ad8..4d385c85a9 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -6,6 +6,7 @@ import type { DefaultEvents, RootMessenger, } from './initialization/defaults'; +import { GenericEncryptor } from './initialization/instances/keyring-controller'; import { InitializationConfiguration } from './initialization/types'; export type WalletOptions = { @@ -19,7 +20,8 @@ export type WalletOptions = { }; export type InstanceSpecificOptions = { - keyringController?: Partial< - Pick - >; + keyringController?: { + encryptor?: GenericEncryptor; + keyringBuilders?: KeyringControllerOptions['keyringBuilders']; + }; }; From 33fc20d954fac21cdb6613e8b465a903cbe1aca2 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 21 May 2026 11:06:22 +0200 Subject: [PATCH 21/26] Fix cast --- .../src/initialization/instances/keyring-controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/wallet/src/initialization/instances/keyring-controller.ts b/packages/wallet/src/initialization/instances/keyring-controller.ts index b311fced30..1b75fedc01 100644 --- a/packages/wallet/src/initialization/instances/keyring-controller.ts +++ b/packages/wallet/src/initialization/instances/keyring-controller.ts @@ -159,9 +159,9 @@ export const keyringController: InitializationConfiguration< state, messenger, keyringBuilders: options.keyringBuilders, - encryptor: - options.encryptor ?? - (encryptorFactory(600_000) as Encryptor), + encryptor: (options.encryptor ?? encryptorFactory(600_000)) as Encryptor< + EncryptionKey | CryptoKey + >, }), getMessenger: (parent) => new Messenger({ From 49b8e9e8e3282354fb4f8a8f9be9055c52edc841 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 21 May 2026 11:24:53 +0200 Subject: [PATCH 22/26] Add MobileEncryptionResult type --- .../initialization/instances/keyring-controller.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/wallet/src/initialization/instances/keyring-controller.ts b/packages/wallet/src/initialization/instances/keyring-controller.ts index 1b75fedc01..8e7c451efb 100644 --- a/packages/wallet/src/initialization/instances/keyring-controller.ts +++ b/packages/wallet/src/initialization/instances/keyring-controller.ts @@ -145,9 +145,21 @@ type MobileEncryptionKey = { keyMetadata: KeyDerivationOptions; }; +type MobileEncryptionResult = { + cipher: string; + iv: string; + salt?: string; + lib?: string; + keyMetadata?: KeyDerivationOptions; +}; + export type GenericEncryptor = | Encryptor - | Encryptor; + | Encryptor< + MobileEncryptionKey, + KeyDerivationOptions, + MobileEncryptionResult + >; export const keyringController: InitializationConfiguration< KeyringController, From 559f32206d92c89b64785c5e0a0cede895709220 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 21 May 2026 14:02:31 +0200 Subject: [PATCH 23/26] Adjust messenger name, add JSDocs, cleanup --- packages/wallet/jest.config.js | 6 +-- packages/wallet/src/Wallet.test.ts | 36 +++++++++++++--- packages/wallet/src/Wallet.ts | 41 +++++++++++++++++-- .../wallet/src/initialization/defaults.ts | 4 +- .../src/initialization/initialization.ts | 23 +++++++---- 5 files changed, 88 insertions(+), 22 deletions(-) diff --git a/packages/wallet/jest.config.js b/packages/wallet/jest.config.js index 046f07de60..571d3d50ca 100644 --- a/packages/wallet/jest.config.js +++ b/packages/wallet/jest.config.js @@ -18,9 +18,9 @@ module.exports = merge(baseConfig, { coverageThreshold: { global: { branches: 100, - functions: 88, - lines: 95.45, - statements: 95.58, + functions: 89.65, + lines: 95.77, + statements: 95.89, }, }, }); diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 3ec1cc1511..df451d1a4f 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -139,12 +139,36 @@ describe('Wallet', () => { expect(Object.keys(wallet.state)).toStrictEqual(['WithMeta', 'NoMeta']); }); + it('disallows modifying the messenger', async () => { + const wallet = await setupWallet(); + + expect(() => { + wallet.messenger = new Messenger({ namespace: 'Root' }); + }).toThrow('The messenger cannot be directly mutated.'); + }); + + it('disallows modifying the state', async () => { + const wallet = await setupWallet(); + + expect(() => { + wallet.state = { KeyringController: { isUnlocked: false, keyrings: [] } }; + }).toThrow('Wallet state cannot be directly mutated.'); + }); + + it('disallows modifying the controller metadata', async () => { + const wallet = await setupWallet(); + + expect(() => { + wallet.controllerMetadata = {}; + }).toThrow('The controller metadata cannot be directly mutated.'); + }); + describe('lifecycle', () => { - it('publishes Wallet:destroyed exactly once on destroy', async () => { + it('publishes Root:destroyed exactly once on destroy', async () => { const wallet = await setupWallet(); const listener = jest.fn(); - wallet.messenger.subscribe('Wallet:destroyed', listener); + wallet.messenger.subscribe('Root:destroyed', listener); await wallet.destroy(); await wallet.destroy(); @@ -152,7 +176,7 @@ describe('Wallet', () => { expect(listener).toHaveBeenCalledTimes(1); }); - it('publishes Wallet:destroyed even if a controller destroy throws synchronously', async () => { + it('publishes Root:destroyed even if a controller destroy throws synchronously', async () => { const wallet = await setupWallet(); jest @@ -162,14 +186,14 @@ describe('Wallet', () => { }); const listener = jest.fn(); - wallet.messenger.subscribe('Wallet:destroyed', listener); + wallet.messenger.subscribe('Root:destroyed', listener); await wallet.destroy(); expect(listener).toHaveBeenCalledTimes(1); }); - it('publishes Wallet:destroyed even if a controller destroy rejects', async () => { + it('publishes Root:destroyed even if a controller destroy rejects', async () => { const wallet = await setupWallet(); jest @@ -177,7 +201,7 @@ describe('Wallet', () => { .mockRejectedValue(new Error('async destroy error') as never); const listener = jest.fn(); - wallet.messenger.subscribe('Wallet:destroyed', listener); + wallet.messenger.subscribe('Root:destroyed', listener); await wallet.destroy(); diff --git a/packages/wallet/src/Wallet.ts b/packages/wallet/src/Wallet.ts index a22ef43641..64bce19606 100644 --- a/packages/wallet/src/Wallet.ts +++ b/packages/wallet/src/Wallet.ts @@ -24,15 +24,26 @@ export class Wallet { #isDestroyed = false; + /** + * Creates a `Wallet` instance, initializing all instances as specified by the passed options. + * + * @param options - Options bag. + * @param options.messenger - An optional messenger to override the default one. + * @param options.state - An optional state blob. + * @param options.initializationConfigurations - An optional list of additional initialization configurations + * required beyond the ones included by default. + * @param options.instanceOptions - An optional object containing options that should be passed + * to specific instances for additional customization. + */ constructor(options: WalletOptions) { this.#messenger = options.messenger ?? new Messenger({ - namespace: 'Wallet', + namespace: 'Root', }); this.#instances = initialize({ - options, + ...options, messenger: this.#messenger, }); @@ -43,10 +54,20 @@ export class Wallet { ); } + /** + * @returns The root messenger of the wallet. + */ get messenger(): Readonly> { return this.#messenger; } + set messenger(_) { + throw new Error('The messenger cannot be directly mutated.'); + } + + /** + * @returns The combined state of the wallet. + */ get state(): DefaultState { return Object.entries(this.#instances).reduce>( (totalState, [name, instance]) => { @@ -57,12 +78,23 @@ export class Wallet { ) as DefaultState; } + set state(_) { + throw new Error('Wallet state cannot be directly mutated.'); + } + + /** + * @returns The controller metadata; containing per-controller information about what properties to persist etc. + */ get controllerMetadata(): Readonly< Record> > { return this.#controllerMetadata; } + set controllerMetadata(_) { + throw new Error('The controller metadata cannot be directly mutated.'); + } + getInstance( name: Name, ): DefaultInstances[Name]; @@ -84,6 +116,9 @@ export class Wallet { return this.#instances[name as keyof DefaultInstances]; } + /** + * Destroy the wallet instance. + */ async destroy(): Promise { if (this.#isDestroyed) { return; @@ -104,6 +139,6 @@ export class Wallet { }), ); - this.messenger.publish('Wallet:destroyed'); + this.messenger.publish('Root:destroyed'); } } diff --git a/packages/wallet/src/initialization/defaults.ts b/packages/wallet/src/initialization/defaults.ts index f1eb7a4fb5..64b30ae2d9 100644 --- a/packages/wallet/src/initialization/defaults.ts +++ b/packages/wallet/src/initialization/defaults.ts @@ -39,7 +39,7 @@ export type DefaultInstances = { export type DefaultActions = MessengerActions; export type WalletDestroyedEvent = { - type: 'Wallet:destroyed'; + type: 'Root:destroyed'; payload: []; }; @@ -50,7 +50,7 @@ export type DefaultEvents = export type RootMessenger< AllowedActions extends ActionConstraint, AllowedEvents extends EventConstraint, -> = Messenger<'Wallet', AllowedActions, AllowedEvents>; +> = Messenger<'Root', AllowedActions, AllowedEvents>; export type DefaultState = { [Key in keyof DefaultInstances]: InstanceState; diff --git a/packages/wallet/src/initialization/initialization.ts b/packages/wallet/src/initialization/initialization.ts index ea00796af4..7374f3c614 100644 --- a/packages/wallet/src/initialization/initialization.ts +++ b/packages/wallet/src/initialization/initialization.ts @@ -6,16 +6,23 @@ import type { } from './defaults'; import { defaultConfigurations, RootMessenger } from './defaults'; -export type InitializeArgs = { - options: WalletOptions; +type InitializeOptions = WalletOptions & { messenger: RootMessenger; }; -export function initialize({ - options, - messenger, -}: InitializeArgs): DefaultInstances { - const { state = {}, initializationConfigurations = [] } = options; +/** + * Initialize all instances based on th default configurations and any additional configurations specified in `options`. + * + * @param options - The wallet options. + * @returns A map containing the instances. + */ +export function initialize(options: InitializeOptions): DefaultInstances { + const { + messenger, + state = {}, + initializationConfigurations = [], + instanceOptions, + } = options; const overriddenConfiguration = initializationConfigurations.map( (config) => config.name, @@ -43,7 +50,7 @@ export function initialize({ // TODO: Consider whether this can be improved state: instanceState as never, messenger: instanceMessenger, - options: options.instanceOptions?.[camelCaseName] ?? {}, + options: instanceOptions?.[camelCaseName] ?? {}, }); instances[name] = instance as Record; From a2c4a8c3a3ceddfdc4403743f75a702e3e40fbef Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 21 May 2026 14:56:49 +0200 Subject: [PATCH 24/26] Address more PR comments --- packages/wallet/src/Wallet.test.ts | 52 ++++--------------- packages/wallet/src/Wallet.ts | 6 +-- packages/wallet/src/index.ts | 1 - .../wallet/src/initialization/defaults.ts | 32 +++++++----- packages/wallet/src/initialization/types.ts | 12 +++++ 5 files changed, 43 insertions(+), 60 deletions(-) diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index df451d1a4f..4d0ee083e3 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -109,7 +109,7 @@ describe('Wallet', () => { foo: 'bar', }); - expect((state as Record).TestService).toBeNull(); + expect((state as Record).TestService).toBeUndefined(); }); it('exposes controllerMetadata for each initialized controller', async () => { @@ -127,9 +127,10 @@ describe('Wallet', () => { foo: { persist: true, includeInDebugSnapshot: false }, }; jest.spyOn(initializationModule, 'initialize').mockReturnValueOnce({ + // @ts-expect-error Mock data. WithMeta: { state: {}, metadata: fakeMetadata }, NoMeta: { state: {} }, - } as never); + }); const wallet = new Wallet({}); @@ -163,50 +164,17 @@ describe('Wallet', () => { }).toThrow('The controller metadata cannot be directly mutated.'); }); - describe('lifecycle', () => { - it('publishes Root:destroyed exactly once on destroy', async () => { - const wallet = await setupWallet(); - - const listener = jest.fn(); - wallet.messenger.subscribe('Root:destroyed', listener); - - await wallet.destroy(); - await wallet.destroy(); - - expect(listener).toHaveBeenCalledTimes(1); - }); - - it('publishes Root:destroyed even if a controller destroy throws synchronously', async () => { - const wallet = await setupWallet(); - - jest - .spyOn(KeyringController.prototype, 'destroy') - .mockImplementation(() => { - throw new Error('sync destroy error'); - }); - - const listener = jest.fn(); - wallet.messenger.subscribe('Root:destroyed', listener); - - await wallet.destroy(); - - expect(listener).toHaveBeenCalledTimes(1); - }); - - it('publishes Root:destroyed even if a controller destroy rejects', async () => { - const wallet = await setupWallet(); + it('calls destroy on instances exactly once', async () => { + const wallet = await setupWallet(); - jest - .spyOn(KeyringController.prototype, 'destroy') - .mockRejectedValue(new Error('async destroy error') as never); + const keyringController = wallet.getInstance('KeyringController'); - const listener = jest.fn(); - wallet.messenger.subscribe('Root:destroyed', listener); + const spy = jest.spyOn(keyringController, 'destroy'); - await wallet.destroy(); + await wallet.destroy(); + await wallet.destroy(); - expect(listener).toHaveBeenCalledTimes(1); - }); + expect(spy).toHaveBeenCalledTimes(1); }); describe('KeyringController', () => { diff --git a/packages/wallet/src/Wallet.ts b/packages/wallet/src/Wallet.ts index 64bce19606..02386c991e 100644 --- a/packages/wallet/src/Wallet.ts +++ b/packages/wallet/src/Wallet.ts @@ -71,7 +71,9 @@ export class Wallet { get state(): DefaultState { return Object.entries(this.#instances).reduce>( (totalState, [name, instance]) => { - totalState[name] = instance.state ?? null; + if (instance.state) { + totalState[name] = instance.state; + } return totalState; }, {}, @@ -138,7 +140,5 @@ export class Wallet { return undefined; }), ); - - this.messenger.publish('Root:destroyed'); } } diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index 24fa6898ad..17a8895c53 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -5,5 +5,4 @@ export type { DefaultEvents, DefaultInstances, RootMessenger, - WalletDestroyedEvent, } from './initialization/defaults'; diff --git a/packages/wallet/src/initialization/defaults.ts b/packages/wallet/src/initialization/defaults.ts index 64b30ae2d9..77ffb943c4 100644 --- a/packages/wallet/src/initialization/defaults.ts +++ b/packages/wallet/src/initialization/defaults.ts @@ -11,41 +11,45 @@ import type { InitializationConfiguration, InstanceState } from './types'; export { defaultConfigurations }; +/** + * Utility type for inferring and extracting an instance type from an initialization configuration. + */ type ExtractInstance = Config extends InitializationConfiguration ? Instance : never; +/** + * Utility type for inferring and extracting an instance messenger type from an initialization configuration. + */ type ExtractInstanceMessenger = Config extends InitializationConfiguration ? InferredMessenger : never; +/** + * Utility type for inferring and extracting the name of an instance from an initialization configuration. + */ type ExtractName = ExtractInstance extends { name: infer Name extends string } ? Name : never; -type Configs = typeof defaultConfigurations; +type DefaultConfigs = typeof defaultConfigurations; -type AllMessengers = ExtractInstanceMessenger; +type AllDefaultMessengers = ExtractInstanceMessenger< + DefaultConfigs[keyof DefaultConfigs] +>; export type DefaultInstances = { - [Key in keyof Configs as ExtractName]: ExtractInstance< - Configs[Key] - >; + [Key in keyof DefaultConfigs as ExtractName< + DefaultConfigs[Key] + >]: ExtractInstance; }; -export type DefaultActions = MessengerActions; +export type DefaultActions = MessengerActions; -export type WalletDestroyedEvent = { - type: 'Root:destroyed'; - payload: []; -}; - -export type DefaultEvents = - | MessengerEvents - | WalletDestroyedEvent; +export type DefaultEvents = MessengerEvents; export type RootMessenger< AllowedActions extends ActionConstraint, diff --git a/packages/wallet/src/initialization/types.ts b/packages/wallet/src/initialization/types.ts index ad36a2c6d8..1e7e51602b 100644 --- a/packages/wallet/src/initialization/types.ts +++ b/packages/wallet/src/initialization/types.ts @@ -1,16 +1,25 @@ import type { InstanceSpecificOptions } from '../types'; import type { DefaultActions, DefaultEvents, RootMessenger } from './defaults'; +/** + * Utility type for inferring the state of an instance. + */ export type InstanceState = Instance extends { state: unknown } ? Instance['state'] : undefined; +/** + * Utility type for inferring the name of an instance. + */ type InstanceName = Instance extends { name: infer Name extends string; } ? Name : string; +/** + * Utility type for lower-casing the first character of an instance name, required for camel-casing. + */ type LowerCaseFirstLetter = Name extends `${infer Character1}${infer Rest}` ? `${Lowercase}${Rest}` @@ -20,6 +29,9 @@ type CamelCaseInstanceName = LowerCaseFirstLetter< InstanceName >; +/** + * Utility type for narrowing the InstanceSpecificOptions to just the options required for the instance. + */ type InstanceOptions = CamelCaseInstanceName extends keyof InstanceSpecificOptions ? NonNullable]> From 5a37811acb90f8fd6a8ac806456170098563d009 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 21 May 2026 15:03:09 +0200 Subject: [PATCH 25/26] Remove unused import --- packages/wallet/src/Wallet.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 4d0ee083e3..97f9b1ee29 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -1,4 +1,3 @@ -import { KeyringController } from '@metamask/keyring-controller'; import { Messenger } from '@metamask/messenger'; import { Json } from '@metamask/utils'; import { webcrypto } from 'crypto'; From 681bd3562a16d127845008c790a14327ed9af93f Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 21 May 2026 15:16:14 +0200 Subject: [PATCH 26/26] Export DefaultState --- packages/wallet/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index 17a8895c53..66b6f88aa3 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -4,5 +4,6 @@ export type { DefaultActions, DefaultEvents, DefaultInstances, + DefaultState, RootMessenger, } from './initialization/defaults';