From 7f78a9ee2d76709fc53aa0bc671f2235939e202d Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 9 Apr 2026 18:19:12 +0200 Subject: [PATCH 01/28] feat(keyring-controller): add withController for atomic operations over multiple keyrings --- packages/keyring-controller/CHANGELOG.md | 1 + .../src/KeyringController.test.ts | 238 ++++++++++++++++++ .../src/KeyringController.ts | 118 +++++++++ .../tests/mocks/mockKeyring.ts | 4 + 4 files changed, 361 insertions(+) diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index c8b5dc752b4..1c41582f81f 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `withKeyringV2` method and `KeyringController:withKeyringV2` messenger action for atomic operations using the `KeyringV2` API ([#8390](https://github.com/MetaMask/core/pull/8390)) - Accepts a `KeyringSelectorV2` to select keyrings by `type`, `address`, `id`, or `filter`. - Ships with default V2 builders for HD (`HdKeyringV2`) and Simple (`SimpleKeyringV2`) keyrings; additional builders can be registered via the `keyringV2Builders` constructor option. +- Add `withController` method for atomically adding and removing keyrings within a single transaction, via a `RestrictedController` object with `addNewKeyring` and `removeKeyring` ### Changed diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index a708d502696..e0be59dc0c2 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -4069,6 +4069,244 @@ describe('KeyringController', () => { }); }); + describe('withController', () => { + it('throws if the controller is locked', async () => { + await withController( + { skipVaultCreation: true }, + async ({ controller }) => { + await expect( + controller.withController(jest.fn()), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); + }, + ); + }); + + it('provides the current keyrings to the callback', async () => { + await withController(async ({ controller, initialState }) => { + await controller.withController(async (restrictedController) => { + expect(restrictedController.keyrings).toHaveLength(1); + expect(restrictedController.keyrings[0].metadata).toStrictEqual( + initialState.keyrings[0].metadata, + ); + }); + }); + }); + + it('returns the result of the callback', async () => { + await withController(async ({ controller }) => { + const result = await controller.withController(async () => 'hello'); + expect(result).toBe('hello'); + }); + }); + + it('throws if the callback returns a raw keyring instance', async () => { + await withController(async ({ controller }) => { + await expect( + controller.withController(async (restrictedController) => { + return restrictedController.keyrings[0].keyring; + }), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsafeDirectKeyringAccess, + ); + }); + }); + + describe('addNewKeyring', () => { + it('creates an initialized keyring and stages it for commit', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.withController(async (restrictedController) => { + const entry = await restrictedController.addNewKeyring( + MockKeyring.type, + ); + + expect(entry.keyring).toBeInstanceOf(MockKeyring); + expect(entry.metadata.id).toBeDefined(); + }); + + expect(controller.state.keyrings).toHaveLength(2); + }, + ); + }); + + it('appears immediately in restrictedController.keyrings', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.withController(async (restrictedController) => { + expect(restrictedController.keyrings).toHaveLength(1); + await restrictedController.addNewKeyring(MockKeyring.type); + expect(restrictedController.keyrings).toHaveLength(2); + }); + }, + ); + }); + + it('destroys created keyrings and does not commit them if the callback throws', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + const destroySpy = jest + .spyOn(MockKeyring.prototype, 'destroy') + .mockResolvedValue(undefined); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await expect( + controller.withController(async (restrictedController) => { + await restrictedController.addNewKeyring(MockKeyring.type); + throw new Error('Oops'); + }), + ).rejects.toThrow('Oops'); + + expect(destroySpy).toHaveBeenCalledTimes(1); + expect(controller.state.keyrings).toHaveLength(1); + }, + ); + }); + }); + + describe('removeKeyring', () => { + it('removes a keyring by id and commits the removal', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + const idToRemove = + controller.state.keyrings[1].metadata.id; + + await controller.withController(async (restrictedController) => { + await restrictedController.removeKeyring(idToRemove); + }); + + expect(controller.state.keyrings).toHaveLength(1); + expect( + controller.state.keyrings.find( + (k) => k.metadata.id === idToRemove, + ), + ).toBeUndefined(); + }, + ); + }); + + it('disappears from restrictedController.keyrings immediately', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + const idToRemove = + controller.state.keyrings[1].metadata.id; + + await controller.withController(async (restrictedController) => { + expect(restrictedController.keyrings).toHaveLength(2); + await restrictedController.removeKeyring(idToRemove); + expect(restrictedController.keyrings).toHaveLength(1); + }); + }, + ); + }); + + it('destroys the removed keyring', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + const destroySpy = jest + .spyOn(MockKeyring.prototype, 'destroy') + .mockResolvedValue(undefined); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + const idToRemove = + controller.state.keyrings[1].metadata.id; + + await controller.withController(async (restrictedController) => { + await restrictedController.removeKeyring(idToRemove); + }); + + expect(destroySpy).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('throws KeyringNotFound for an unknown id', async () => { + await withController(async ({ controller }) => { + await expect( + controller.withController(async (restrictedController) => { + await restrictedController.removeKeyring('non-existent-id'); + }), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + }); + }); + + it('destroys a keyring that was created then removed within the same callback', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + const destroySpy = jest + .spyOn(MockKeyring.prototype, 'destroy') + .mockResolvedValue(undefined); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.withController(async (restrictedController) => { + const { metadata } = await restrictedController.addNewKeyring( + MockKeyring.type, + ); + await restrictedController.removeKeyring(metadata.id); + }); + + expect(destroySpy).toHaveBeenCalledTimes(1); + expect(controller.state.keyrings).toHaveLength(1); + }, + ); + }); + }); + + it('rolls back on error', async () => { + await withController(async ({ controller, initialState }) => { + await expect( + controller.withController(async (restrictedController) => { + await restrictedController.addNewKeyring(KeyringTypes.simple); + throw new Error('Oops'); + }), + ).rejects.toThrow('Oops'); + + expect(controller.state.keyrings).toHaveLength( + initialState.keyrings.length, + ); + expect(await controller.getAccounts()).toStrictEqual( + initialState.keyrings[0].accounts, + ); + }); + }); + + it('does not update the vault if no keyrings change', async () => { + await withController(async ({ controller, encryptor }) => { + const encryptSpy = jest.spyOn(encryptor, 'encrypt'); + + await controller.withController(async () => { + // no-op + }); + + expect(encryptSpy).not.toHaveBeenCalled(); + }); + }); + }); + describe('withKeyringUnsafe', () => { it('calls the given function without acquiring the lock', async () => { await withController(async ({ controller }) => { diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index 5324b7b2ac0..2c3d68f0740 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -246,6 +246,43 @@ type KeyringEntry = { metadata: KeyringMetadata; }; +/** + * A restricted view of the {@link KeyringController} exposed to the callback + * passed to {@link KeyringController.withController}. + * + * It provides a read-only live view of all keyrings and the ability to stage + * keyring additions and removals atomically within a single transaction. + */ +export type RestrictedController = { + /** + * Read-only live view of all keyrings in the current transaction (original + * keyrings plus any added, minus any removed so far in this callback). + */ + readonly keyrings: ReadonlyArray<{ + keyring: EthKeyring; + metadata: KeyringMetadata; + }>; + /** + * Create a new keyring of the given type and stage it for commit. The new + * entry is immediately visible in {@link RestrictedController.keyrings}. + * + * @param type - The type of keyring to create. + * @param opts - Optional data to pass to the keyring builder. + * @returns The newly created `{ keyring, metadata }` entry. + */ + addNewKeyring( + type: string, + opts?: unknown, + ): Promise<{ keyring: EthKeyring; metadata: KeyringMetadata }>; + /** + * Stage the keyring with the given id for removal. The keyring is + * immediately removed from {@link RestrictedController.keyrings}. + * + * @param id - The id of the keyring to remove. + */ + removeKeyring(id: string): Promise; +}; + /** * A strategy for importing an account */ @@ -1852,6 +1889,87 @@ export class KeyringController< }); } + /** + * Execute an operation against all keyrings as a mutually exclusive atomic + * operation. The operation receives a {@link RestrictedController} instance + * that exposes a read-only live view of all keyrings as well as + * `addNewKeyring` and `removeKeyring` methods to stage mutations. + * + * The method automatically persists changes at the end of the function + * execution, or rolls back the changes if an error is thrown. + * + * @param operation - Function to execute with the restricted controller. + * @returns Promise resolving to the result of the function execution. + * @template CallbackResult - The type of the value resolved by the callback function. + */ + async withController( + operation: ( + restrictedController: RestrictedController, + ) => Promise, + ): Promise { + this.#assertIsUnlocked(); + + return this.#persistOrRollback(async () => { + const liveKeyrings = [...this.#keyrings]; + const createdEntries = new Set<{ + keyring: EthKeyring; + metadata: KeyringMetadata; + }>(); + const removedEntries = new Set<{ + keyring: EthKeyring; + metadata: KeyringMetadata; + }>(); + + const restrictedController: RestrictedController = { + get keyrings() { + return Object.freeze([...liveKeyrings]) as RestrictedController['keyrings']; + }, + addNewKeyring: async (type: string, opts?: unknown) => { + const keyring = await this.#createKeyring(type, opts); + const entry = { keyring, metadata: getDefaultKeyringMetadata() }; + liveKeyrings.push(entry); + createdEntries.add(entry); + return entry; + }, + removeKeyring: async (id: string) => { + const idx = liveKeyrings.findIndex((e) => e.metadata.id === id); + if (idx === -1) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + const [removed] = liveKeyrings.splice(idx, 1) as [ + { keyring: EthKeyring; metadata: KeyringMetadata }, + ]; + removedEntries.add(removed); + }, + }; + + let result: CallbackResult; + try { + result = await operation(restrictedController); + } catch (error) { + await Promise.all( + [...createdEntries].map(({ keyring }) => + this.#destroyKeyring(keyring), + ), + ); + throw error; + } + + await Promise.all( + [...removedEntries].map(({ keyring }) => this.#destroyKeyring(keyring)), + ); + this.#keyrings = liveKeyrings; + + for (const { keyring } of this.#keyrings) { + this.#assertNoUnsafeDirectKeyringAccess(result, keyring); + } + + return result; + }); + } + /** * Select a keyring and execute the given operation with the selected * keyring, **without** acquiring the controller's mutual exclusion lock. diff --git a/packages/keyring-controller/tests/mocks/mockKeyring.ts b/packages/keyring-controller/tests/mocks/mockKeyring.ts index 4bfbeb77c52..89bff0f1abd 100644 --- a/packages/keyring-controller/tests/mocks/mockKeyring.ts +++ b/packages/keyring-controller/tests/mocks/mockKeyring.ts @@ -32,4 +32,8 @@ export class MockKeyring implements EthKeyring { async deserialize(_: unknown): Promise { return Promise.resolve(); } + + async destroy(): Promise { + return Promise.resolve(); + } } From 6fe4801b5279edf67246f11befc81c7568b7ad93 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 9 Apr 2026 18:37:15 +0200 Subject: [PATCH 02/28] chore: changelog --- packages/keyring-controller/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index 1c41582f81f..3e5c1ccdffd 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `withController` action to run atomic operations on multiple keyrings (within a single transaction) ([#8416](https://github.com/MetaMask/core/pull/8416)) + - This action takes a subset of the controllr (a `RestrictedController` object) that exposes `addNewKeyring` and `removeKeyring` methods to add and remove keyring during that transaction call. - Expose `KeyringController:signTransaction` method through `KeyringController` messenger ([#8408](https://github.com/MetaMask/core/pull/8408)) - Persist vault when keyring state changes during unlock ([#8415](https://github.com/MetaMask/core/pull/8415)) - If a keyring's serialized state differs after deserialization (e.g. a migration ran, or metadata was missing), the vault is now re-persisted so the change is not lost on the next unlock. @@ -22,7 +24,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `withKeyringV2` method and `KeyringController:withKeyringV2` messenger action for atomic operations using the `KeyringV2` API ([#8390](https://github.com/MetaMask/core/pull/8390)) - Accepts a `KeyringSelectorV2` to select keyrings by `type`, `address`, `id`, or `filter`. - Ships with default V2 builders for HD (`HdKeyringV2`) and Simple (`SimpleKeyringV2`) keyrings; additional builders can be registered via the `keyringV2Builders` constructor option. -- Add `withController` method for atomically adding and removing keyrings within a single transaction, via a `RestrictedController` object with `addNewKeyring` and `removeKeyring` ### Changed From e43f88c6f2c23cb4ebc9fa058b9a6f463aa9472e Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 9 Apr 2026 18:40:01 +0200 Subject: [PATCH 03/28] feat: expose withController as an action --- .../KeyringController-method-action-types.ts | 19 +++++++++++++++++++ .../src/KeyringController.ts | 1 + 2 files changed, 20 insertions(+) diff --git a/packages/keyring-controller/src/KeyringController-method-action-types.ts b/packages/keyring-controller/src/KeyringController-method-action-types.ts index 6fb608c050d..192fc45645d 100644 --- a/packages/keyring-controller/src/KeyringController-method-action-types.ts +++ b/packages/keyring-controller/src/KeyringController-method-action-types.ts @@ -271,6 +271,24 @@ export type KeyringControllerWithKeyringAction = { handler: KeyringController['withKeyring']; }; +/** + * Execute an operation against all keyrings as a mutually exclusive atomic + * operation. The operation receives a {@link RestrictedController} instance + * that exposes a read-only live view of all keyrings as well as + * `addNewKeyring` and `removeKeyring` methods to stage mutations. + * + * The method automatically persists changes at the end of the function + * execution, or rolls back the changes if an error is thrown. + * + * @param operation - Function to execute with the restricted controller. + * @returns Promise resolving to the result of the function execution. + * @template CallbackResult - The type of the value resolved by the callback function. + */ +export type KeyringControllerWithControllerAction = { + type: `KeyringController:withController`; + handler: KeyringController['withController']; +}; + /** * Select a keyring and execute the given operation with the selected * keyring, **without** acquiring the controller's mutual exclusion lock. @@ -399,6 +417,7 @@ export type KeyringControllerMethodActions = | KeyringControllerPatchUserOperationAction | KeyringControllerSignUserOperationAction | KeyringControllerWithKeyringAction + | KeyringControllerWithControllerAction | KeyringControllerWithKeyringUnsafeAction | KeyringControllerWithKeyringV2Action | KeyringControllerWithKeyringV2UnsafeAction; diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index 2c3d68f0740..e512b7c06ed 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -65,6 +65,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'patchUserOperation', 'signUserOperation', 'addNewAccount', + 'withController', 'withKeyring', 'withKeyringUnsafe', 'withKeyringV2', From 9301d9c9be728848b0fc9d00e1e2e1a8ea85b209 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 9 Apr 2026 18:42:14 +0200 Subject: [PATCH 04/28] test: add test for action --- .../src/KeyringController.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index e0be59dc0c2..87dcc73e2e2 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -5264,6 +5264,28 @@ describe('KeyringController', () => { }); }); + describe('withController', () => { + it('should call withController', async () => { + await withController(async ({ messenger }) => { + const operation = jest.fn().mockResolvedValue('result'); + + const actionReturnValue = await messenger.call( + 'KeyringController:withController', + operation, + ); + + expect(operation).toHaveBeenCalledWith( + expect.objectContaining({ + keyrings: expect.any(Array), + addNewKeyring: expect.any(Function), + removeKeyring: expect.any(Function), + }), + ); + expect(actionReturnValue).toBe('result'); + }); + }); + }); + describe('addNewKeyring', () => { it('should call addNewKeyring', async () => { const mockKeyringMetadata: KeyringMetadata = { From 98dfffe14206e5d56c171f8fe8ebaa08fe4972f3 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 9 Apr 2026 19:14:51 +0200 Subject: [PATCH 05/28] chore: lint --- .../src/KeyringController.test.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index 87dcc73e2e2..f9c27ec6a3a 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -4074,9 +4074,9 @@ describe('KeyringController', () => { await withController( { skipVaultCreation: true }, async ({ controller }) => { - await expect( - controller.withController(jest.fn()), - ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); + await expect(controller.withController(jest.fn())).rejects.toThrow( + KeyringControllerErrorMessage.ControllerLocked, + ); }, ); }); @@ -4182,8 +4182,7 @@ describe('KeyringController', () => { { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, async ({ controller }) => { await controller.addNewKeyring(MockKeyring.type); - const idToRemove = - controller.state.keyrings[1].metadata.id; + const idToRemove = controller.state.keyrings[1].metadata.id; await controller.withController(async (restrictedController) => { await restrictedController.removeKeyring(idToRemove); @@ -4207,8 +4206,7 @@ describe('KeyringController', () => { { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, async ({ controller }) => { await controller.addNewKeyring(MockKeyring.type); - const idToRemove = - controller.state.keyrings[1].metadata.id; + const idToRemove = controller.state.keyrings[1].metadata.id; await controller.withController(async (restrictedController) => { expect(restrictedController.keyrings).toHaveLength(2); @@ -4230,8 +4228,7 @@ describe('KeyringController', () => { { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, async ({ controller }) => { await controller.addNewKeyring(MockKeyring.type); - const idToRemove = - controller.state.keyrings[1].metadata.id; + const idToRemove = controller.state.keyrings[1].metadata.id; await controller.withController(async (restrictedController) => { await restrictedController.removeKeyring(idToRemove); From e54308ab408d8bdbeb12d2113cf94afd3b57cd9b Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 9 Apr 2026 19:16:06 +0200 Subject: [PATCH 06/28] refactor: better comments + add KeyringEntry type --- .../src/KeyringController.ts | 107 +++++++++--------- 1 file changed, 53 insertions(+), 54 deletions(-) diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index e512b7c06ed..fc3d156eacd 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -259,10 +259,7 @@ export type RestrictedController = { * Read-only live view of all keyrings in the current transaction (original * keyrings plus any added, minus any removed so far in this callback). */ - readonly keyrings: ReadonlyArray<{ - keyring: EthKeyring; - metadata: KeyringMetadata; - }>; + readonly keyrings: readonly KeyringEntry[]; /** * Create a new keyring of the given type and stage it for commit. The new * entry is immediately visible in {@link RestrictedController.keyrings}. @@ -702,10 +699,7 @@ function isSerializedKeyringsArray( async function displayForKeyring({ keyring, metadata, -}: { - keyring: EthKeyring; - metadata: KeyringMetadata; -}): Promise { +}: KeyringEntry): Promise { const accounts = await keyring.getAccounts(); return { @@ -1798,13 +1792,7 @@ export class KeyringController< CallbackResult = void, >( selector: KeyringSelector, - operation: ({ - keyring, - metadata, - }: { - keyring: SelectedKeyring; - metadata: KeyringMetadata; - }) => Promise, + operation: ({ keyring, metadata }: KeyringEntry) => Promise, // eslint-disable-next-line @typescript-eslint/unified-signatures options: | { createIfMissing?: false } @@ -1831,13 +1819,7 @@ export class KeyringController< CallbackResult = void, >( selector: KeyringSelector, - operation: ({ - keyring, - metadata, - }: { - keyring: SelectedKeyring; - metadata: KeyringMetadata; - }) => Promise, + operation: ({ keyring, metadata }: KeyringEntry) => Promise, ): Promise; async withKeyring< @@ -1845,13 +1827,7 @@ export class KeyringController< CallbackResult = void, >( selector: KeyringSelector, - operation: ({ - keyring, - metadata, - }: { - keyring: SelectedKeyring; - metadata: KeyringMetadata; - }) => Promise, + operation: ({ keyring, metadata }: KeyringEntry) => Promise, options: | { createIfMissing?: false } | { createIfMissing: true; createWithData?: unknown } = { @@ -1911,58 +1887,81 @@ export class KeyringController< this.#assertIsUnlocked(); return this.#persistOrRollback(async () => { - const liveKeyrings = [...this.#keyrings]; - const createdEntries = new Set<{ - keyring: EthKeyring; - metadata: KeyringMetadata; - }>(); - const removedEntries = new Set<{ - keyring: EthKeyring; - metadata: KeyringMetadata; - }>(); - + // Track created and removed keyrings during the operation execution. + const createdEntries = new Set(); + const removedEntries = new Set(); + + // Copy of the current keyrings that is mutated during the operation execution. + const restrictedEntries = [...this.#keyrings]; + + // The restricted controller proxies the current keyrings and allows staging + // mutations that are only applied to the real keyrings if the operation + // completes successfully. This allows us to have a single source of truth + // for the keyrings during the operation execution, and to automatically + // roll back any changes if an error is thrown. const restrictedController: RestrictedController = { + // We freeze the array to prevent direct mutations, but the keyring instances + // themselves are not frozen, allowing safe read-only access. get keyrings() { - return Object.freeze([...liveKeyrings]) as RestrictedController['keyrings']; + return Object.freeze([ + ...restrictedEntries, + ]) as RestrictedController['keyrings']; }, + + // Method to create a new keyring and adds it to the restricted entries. addNewKeyring: async (type: string, opts?: unknown) => { const keyring = await this.#createKeyring(type, opts); const entry = { keyring, metadata: getDefaultKeyringMetadata() }; - liveKeyrings.push(entry); + + restrictedEntries.push(entry); createdEntries.add(entry); + return entry; }, + + // Method to remove a keyring from the restricted entries. removeKeyring: async (id: string) => { - const idx = liveKeyrings.findIndex((e) => e.metadata.id === id); - if (idx === -1) { + const index = restrictedEntries.findIndex( + (entry) => entry.metadata.id === id, + ); + if (index === -1) { throw new KeyringControllerError( KeyringControllerErrorMessage.KeyringNotFound, ); } - const [removed] = liveKeyrings.splice(idx, 1) as [ - { keyring: EthKeyring; metadata: KeyringMetadata }, + + const [removed] = restrictedEntries.splice(index, 1) as [ + KeyringEntry, ]; removedEntries.add(removed); }, }; + const destroyKeyrings = async ( + entries: Iterable, + ): Promise => { + await Promise.all( + [...entries].map(({ keyring }) => this.#destroyKeyring(keyring)), + ); + }; + let result: CallbackResult; try { result = await operation(restrictedController); } catch (error) { - await Promise.all( - [...createdEntries].map(({ keyring }) => - this.#destroyKeyring(keyring), - ), - ); + await destroyKeyrings(createdEntries); + throw error; } - await Promise.all( - [...removedEntries].map(({ keyring }) => this.#destroyKeyring(keyring)), - ); - this.#keyrings = liveKeyrings; + await destroyKeyrings(removedEntries); + + // We update the real keyrings only after the operation completes successfully, so that + // they will be persisted in the vault. + this.#keyrings = restrictedEntries; + // As usual, we want to prevent returning direct references to keyring instances, so we check + // the result for any unsafe direct access before returning. for (const { keyring } of this.#keyrings) { this.#assertNoUnsafeDirectKeyringAccess(result, keyring); } From 04beb30bc41d0b8c6f5c745954137a381c8e04ca Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 13 Apr 2026 16:12:37 +0200 Subject: [PATCH 07/28] chore: docs --- packages/keyring-controller/src/KeyringController.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index fc3d156eacd..c49f2ee62af 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -230,6 +230,9 @@ export type KeyringMetadata = { name: string; }; +/** + * A keyring entry, including the keyring instance (+ v2 instance) and its metadata. + */ type KeyringEntry = { /** * The keyring instance. From e6c9db514782004d1e4a4e08a53fcc5f7e77b697 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 13 Apr 2026 16:13:03 +0200 Subject: [PATCH 08/28] chore: lint --- packages/keyring-controller/src/KeyringController.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index c49f2ee62af..28bad5ca77d 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -1906,9 +1906,7 @@ export class KeyringController< // We freeze the array to prevent direct mutations, but the keyring instances // themselves are not frozen, allowing safe read-only access. get keyrings() { - return Object.freeze([ - ...restrictedEntries, - ]) as RestrictedController['keyrings']; + return Object.freeze([...restrictedEntries]); }, // Method to create a new keyring and adds it to the restricted entries. From cfa9cc272337d45c684a93a9caa8183e69c2e61c Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 13 Apr 2026 16:45:17 +0200 Subject: [PATCH 09/28] refactor: use KeyringEntry in RestrictedController + export it --- packages/keyring-controller/src/KeyringController.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index 28bad5ca77d..ab2e866090a 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -233,7 +233,7 @@ export type KeyringMetadata = { /** * A keyring entry, including the keyring instance (+ v2 instance) and its metadata. */ -type KeyringEntry = { +export type KeyringEntry = { /** * The keyring instance. */ @@ -271,10 +271,7 @@ export type RestrictedController = { * @param opts - Optional data to pass to the keyring builder. * @returns The newly created `{ keyring, metadata }` entry. */ - addNewKeyring( - type: string, - opts?: unknown, - ): Promise<{ keyring: EthKeyring; metadata: KeyringMetadata }>; + addNewKeyring(type: string, opts?: unknown): Promise; /** * Stage the keyring with the given id for removal. The keyring is * immediately removed from {@link RestrictedController.keyrings}. From 001d092bba9032f6c68a7258763b70f83405c00d Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 13 Apr 2026 17:12:31 +0200 Subject: [PATCH 10/28] fix: fix addNewKeyring --- packages/keyring-controller/src/KeyringController.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index ab2e866090a..f2abbde94e8 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -1908,8 +1908,7 @@ export class KeyringController< // Method to create a new keyring and adds it to the restricted entries. addNewKeyring: async (type: string, opts?: unknown) => { - const keyring = await this.#createKeyring(type, opts); - const entry = { keyring, metadata: getDefaultKeyringMetadata() }; + const entry = await this.#createKeyring(type, opts); restrictedEntries.push(entry); createdEntries.add(entry); From 5c291ea1428c2ead5440cbc1ae33c8a474d02ff7 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 13 Apr 2026 18:03:51 +0200 Subject: [PATCH 11/28] fix: properly destroy v2 instances --- packages/keyring-controller/src/KeyringController.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index f2abbde94e8..f5e9a48207b 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -1938,7 +1938,9 @@ export class KeyringController< entries: Iterable, ): Promise => { await Promise.all( - [...entries].map(({ keyring }) => this.#destroyKeyring(keyring)), + [...entries].map(({ keyring, keyringV2 }) => + this.#destroyKeyring(keyring, keyringV2), + ), ); }; From 02a9facd24460dba7b03aca9f15723c51d7ee821 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 13 Apr 2026 18:05:04 +0200 Subject: [PATCH 12/28] fix: assert v2 instances --- .../keyring-controller/src/KeyringController.test.ts | 12 ++++++++++++ packages/keyring-controller/src/KeyringController.ts | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index f9c27ec6a3a..6276a9fbe47 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -4111,6 +4111,18 @@ describe('KeyringController', () => { }); }); + it('throws if the callback returns a raw keyring (v2) instance', async () => { + await withController(async ({ controller }) => { + await expect( + controller.withController(async (restrictedController) => { + return restrictedController.keyrings[0].keyringV2; + }), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsafeDirectKeyringAccess, + ); + }); + }); + describe('addNewKeyring', () => { it('creates an initialized keyring and stages it for commit', async () => { const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index f5e9a48207b..486c10ed16e 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -1961,8 +1961,11 @@ export class KeyringController< // As usual, we want to prevent returning direct references to keyring instances, so we check // the result for any unsafe direct access before returning. - for (const { keyring } of this.#keyrings) { + for (const { keyring, keyringV2 } of this.#keyrings) { this.#assertNoUnsafeDirectKeyringAccess(result, keyring); + if (keyringV2) { + this.#assertNoUnsafeDirectKeyringAccess(result, keyringV2); + } } return result; From 491434bf9fbd9689e3406a80300e506926dcc5d0 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 13 Apr 2026 18:21:33 +0200 Subject: [PATCH 13/28] fix: missing export --- packages/keyring-controller/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/keyring-controller/src/index.ts b/packages/keyring-controller/src/index.ts index 9ac85f95897..4d570f0c98e 100644 --- a/packages/keyring-controller/src/index.ts +++ b/packages/keyring-controller/src/index.ts @@ -19,6 +19,7 @@ export type { KeyringControllerPrepareUserOperationAction, KeyringControllerPatchUserOperationAction, KeyringControllerSignUserOperationAction, + KeyringControllerWithControllerAction, KeyringControllerWithKeyringAction, KeyringControllerWithKeyringUnsafeAction, KeyringControllerWithKeyringV2Action, From 019cc603983166f2709c5a8341e4b74738628a03 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 13 Apr 2026 18:23:41 +0200 Subject: [PATCH 14/28] fix: re-use SelectedKeyring in withKeyring --- packages/keyring-controller/src/KeyringController.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index 486c10ed16e..7309d7071ab 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -1827,7 +1827,13 @@ export class KeyringController< CallbackResult = void, >( selector: KeyringSelector, - operation: ({ keyring, metadata }: KeyringEntry) => Promise, + operation: ({ + keyring, + metadata, + }: { + keyring: SelectedKeyring; + metadata: KeyringMetadata; + }) => Promise, options: | { createIfMissing?: false } | { createIfMissing: true; createWithData?: unknown } = { From 559ca381da97e71d0c1f6e7f02704bf8d44b86cc Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 13 Apr 2026 18:25:09 +0200 Subject: [PATCH 15/28] refactor: cosmetic (move withController block) --- .../src/KeyringController.ts | 212 +++++++++--------- 1 file changed, 106 insertions(+), 106 deletions(-) diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index 7309d7071ab..6bacc2d7ba2 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -1872,112 +1872,6 @@ export class KeyringController< }); } - /** - * Execute an operation against all keyrings as a mutually exclusive atomic - * operation. The operation receives a {@link RestrictedController} instance - * that exposes a read-only live view of all keyrings as well as - * `addNewKeyring` and `removeKeyring` methods to stage mutations. - * - * The method automatically persists changes at the end of the function - * execution, or rolls back the changes if an error is thrown. - * - * @param operation - Function to execute with the restricted controller. - * @returns Promise resolving to the result of the function execution. - * @template CallbackResult - The type of the value resolved by the callback function. - */ - async withController( - operation: ( - restrictedController: RestrictedController, - ) => Promise, - ): Promise { - this.#assertIsUnlocked(); - - return this.#persistOrRollback(async () => { - // Track created and removed keyrings during the operation execution. - const createdEntries = new Set(); - const removedEntries = new Set(); - - // Copy of the current keyrings that is mutated during the operation execution. - const restrictedEntries = [...this.#keyrings]; - - // The restricted controller proxies the current keyrings and allows staging - // mutations that are only applied to the real keyrings if the operation - // completes successfully. This allows us to have a single source of truth - // for the keyrings during the operation execution, and to automatically - // roll back any changes if an error is thrown. - const restrictedController: RestrictedController = { - // We freeze the array to prevent direct mutations, but the keyring instances - // themselves are not frozen, allowing safe read-only access. - get keyrings() { - return Object.freeze([...restrictedEntries]); - }, - - // Method to create a new keyring and adds it to the restricted entries. - addNewKeyring: async (type: string, opts?: unknown) => { - const entry = await this.#createKeyring(type, opts); - - restrictedEntries.push(entry); - createdEntries.add(entry); - - return entry; - }, - - // Method to remove a keyring from the restricted entries. - removeKeyring: async (id: string) => { - const index = restrictedEntries.findIndex( - (entry) => entry.metadata.id === id, - ); - if (index === -1) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.KeyringNotFound, - ); - } - - const [removed] = restrictedEntries.splice(index, 1) as [ - KeyringEntry, - ]; - removedEntries.add(removed); - }, - }; - - const destroyKeyrings = async ( - entries: Iterable, - ): Promise => { - await Promise.all( - [...entries].map(({ keyring, keyringV2 }) => - this.#destroyKeyring(keyring, keyringV2), - ), - ); - }; - - let result: CallbackResult; - try { - result = await operation(restrictedController); - } catch (error) { - await destroyKeyrings(createdEntries); - - throw error; - } - - await destroyKeyrings(removedEntries); - - // We update the real keyrings only after the operation completes successfully, so that - // they will be persisted in the vault. - this.#keyrings = restrictedEntries; - - // As usual, we want to prevent returning direct references to keyring instances, so we check - // the result for any unsafe direct access before returning. - for (const { keyring, keyringV2 } of this.#keyrings) { - this.#assertNoUnsafeDirectKeyringAccess(result, keyring); - if (keyringV2) { - this.#assertNoUnsafeDirectKeyringAccess(result, keyringV2); - } - } - - return result; - }); - } - /** * Select a keyring and execute the given operation with the selected * keyring, **without** acquiring the controller's mutual exclusion lock. @@ -2190,6 +2084,112 @@ export class KeyringController< ); } + /** + * Execute an operation against all keyrings as a mutually exclusive atomic + * operation. The operation receives a {@link RestrictedController} instance + * that exposes a read-only live view of all keyrings as well as + * `addNewKeyring` and `removeKeyring` methods to stage mutations. + * + * The method automatically persists changes at the end of the function + * execution, or rolls back the changes if an error is thrown. + * + * @param operation - Function to execute with the restricted controller. + * @returns Promise resolving to the result of the function execution. + * @template CallbackResult - The type of the value resolved by the callback function. + */ + async withController( + operation: ( + restrictedController: RestrictedController, + ) => Promise, + ): Promise { + this.#assertIsUnlocked(); + + return this.#persistOrRollback(async () => { + // Track created and removed keyrings during the operation execution. + const createdEntries = new Set(); + const removedEntries = new Set(); + + // Copy of the current keyrings that is mutated during the operation execution. + const restrictedEntries = [...this.#keyrings]; + + // The restricted controller proxies the current keyrings and allows staging + // mutations that are only applied to the real keyrings if the operation + // completes successfully. This allows us to have a single source of truth + // for the keyrings during the operation execution, and to automatically + // roll back any changes if an error is thrown. + const restrictedController: RestrictedController = { + // We freeze the array to prevent direct mutations, but the keyring instances + // themselves are not frozen, allowing safe read-only access. + get keyrings() { + return Object.freeze([...restrictedEntries]); + }, + + // Method to create a new keyring and adds it to the restricted entries. + addNewKeyring: async (type: string, opts?: unknown) => { + const entry = await this.#createKeyring(type, opts); + + restrictedEntries.push(entry); + createdEntries.add(entry); + + return entry; + }, + + // Method to remove a keyring from the restricted entries. + removeKeyring: async (id: string) => { + const index = restrictedEntries.findIndex( + (entry) => entry.metadata.id === id, + ); + if (index === -1) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + + const [removed] = restrictedEntries.splice(index, 1) as [ + KeyringEntry, + ]; + removedEntries.add(removed); + }, + }; + + const destroyKeyrings = async ( + entries: Iterable, + ): Promise => { + await Promise.all( + [...entries].map(({ keyring, keyringV2 }) => + this.#destroyKeyring(keyring, keyringV2), + ), + ); + }; + + let result: CallbackResult; + try { + result = await operation(restrictedController); + } catch (error) { + await destroyKeyrings(createdEntries); + + throw error; + } + + await destroyKeyrings(removedEntries); + + // We update the real keyrings only after the operation completes successfully, so that + // they will be persisted in the vault. + this.#keyrings = restrictedEntries; + + // As usual, we want to prevent returning direct references to keyring instances, so we check + // the result for any unsafe direct access before returning. + for (const { keyring, keyringV2 } of this.#keyrings) { + this.#assertNoUnsafeDirectKeyringAccess(result, keyring); + if (keyringV2) { + this.#assertNoUnsafeDirectKeyringAccess(result, keyringV2); + } + } + + return result; + }); + } + async getAccountKeyringType(account: string): Promise { this.#assertIsUnlocked(); From ba3dbf4440decfae52478f013a1f87f8c661cf4b Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 13 Apr 2026 18:27:05 +0200 Subject: [PATCH 16/28] fix: check for removed keyrings unsafe access too --- packages/keyring-controller/src/KeyringController.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index 6bacc2d7ba2..9de11e20780 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -2179,7 +2179,12 @@ export class KeyringController< // As usual, we want to prevent returning direct references to keyring instances, so we check // the result for any unsafe direct access before returning. - for (const { keyring, keyringV2 } of this.#keyrings) { + for (const { keyring, keyringV2 } of [ + ...this.#keyrings, + // We also check for keyrings that got removed during the operation, since the result could + // still have references to them. + ...removedEntries, + ]) { this.#assertNoUnsafeDirectKeyringAccess(result, keyring); if (keyringV2) { this.#assertNoUnsafeDirectKeyringAccess(result, keyringV2); From e60c9782423cf6a862f6b28312b6c3bcdd1d9359 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 13 Apr 2026 18:32:49 +0200 Subject: [PATCH 17/28] refactor: cosmetic (move withController block) --- .../KeyringController-method-action-types.ts | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/keyring-controller/src/KeyringController-method-action-types.ts b/packages/keyring-controller/src/KeyringController-method-action-types.ts index 192fc45645d..3f1bd62fee4 100644 --- a/packages/keyring-controller/src/KeyringController-method-action-types.ts +++ b/packages/keyring-controller/src/KeyringController-method-action-types.ts @@ -271,24 +271,6 @@ export type KeyringControllerWithKeyringAction = { handler: KeyringController['withKeyring']; }; -/** - * Execute an operation against all keyrings as a mutually exclusive atomic - * operation. The operation receives a {@link RestrictedController} instance - * that exposes a read-only live view of all keyrings as well as - * `addNewKeyring` and `removeKeyring` methods to stage mutations. - * - * The method automatically persists changes at the end of the function - * execution, or rolls back the changes if an error is thrown. - * - * @param operation - Function to execute with the restricted controller. - * @returns Promise resolving to the result of the function execution. - * @template CallbackResult - The type of the value resolved by the callback function. - */ -export type KeyringControllerWithControllerAction = { - type: `KeyringController:withController`; - handler: KeyringController['withController']; -}; - /** * Select a keyring and execute the given operation with the selected * keyring, **without** acquiring the controller's mutual exclusion lock. @@ -393,6 +375,24 @@ export type KeyringControllerWithKeyringV2UnsafeAction = { handler: KeyringController['withKeyringV2Unsafe']; }; +/** + * Execute an operation against all keyrings as a mutually exclusive atomic + * operation. The operation receives a {@link RestrictedController} instance + * that exposes a read-only live view of all keyrings as well as + * `addNewKeyring` and `removeKeyring` methods to stage mutations. + * + * The method automatically persists changes at the end of the function + * execution, or rolls back the changes if an error is thrown. + * + * @param operation - Function to execute with the restricted controller. + * @returns Promise resolving to the result of the function execution. + * @template CallbackResult - The type of the value resolved by the callback function. + */ +export type KeyringControllerWithControllerAction = { + type: `KeyringController:withController`; + handler: KeyringController['withController']; +}; + /** * Union of all KeyringController action types. */ @@ -417,7 +417,7 @@ export type KeyringControllerMethodActions = | KeyringControllerPatchUserOperationAction | KeyringControllerSignUserOperationAction | KeyringControllerWithKeyringAction - | KeyringControllerWithControllerAction | KeyringControllerWithKeyringUnsafeAction | KeyringControllerWithKeyringV2Action - | KeyringControllerWithKeyringV2UnsafeAction; + | KeyringControllerWithKeyringV2UnsafeAction + | KeyringControllerWithControllerAction; From 21276f6f1caffd172fc3762954abdb50759141cb Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 13 Apr 2026 18:46:24 +0200 Subject: [PATCH 18/28] chore: changelog --- packages/keyring-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index 3e5c1ccdffd..3daa3a4882b 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `withController` action to run atomic operations on multiple keyrings (within a single transaction) ([#8416](https://github.com/MetaMask/core/pull/8416)) - - This action takes a subset of the controllr (a `RestrictedController` object) that exposes `addNewKeyring` and `removeKeyring` methods to add and remove keyring during that transaction call. + - This action uses a `RestrictedController` object that exposes `addNewKeyring` and `removeKeyring` methods to add and remove keyring during the transaction (atomic) call. - Expose `KeyringController:signTransaction` method through `KeyringController` messenger ([#8408](https://github.com/MetaMask/core/pull/8408)) - Persist vault when keyring state changes during unlock ([#8415](https://github.com/MetaMask/core/pull/8415)) - If a keyring's serialized state differs after deserialization (e.g. a migration ran, or metadata was missing), the vault is now re-persisted so the change is not lost on the next unlock. From d12e369044855281a1d75e1023068edb6ee777da Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 14 Apr 2026 10:22:56 +0200 Subject: [PATCH 19/28] test: add test for v2 with RestrictedController.addNewKeyring --- .../keyring-controller/src/KeyringController.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index 6276a9fbe47..0f68b153943 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -4145,6 +4145,18 @@ describe('KeyringController', () => { ); }); + it('populates keyringV2 when a V2 builder is registered for the type', async () => { + await withController(async ({ controller }) => { + await controller.withController(async (restrictedController) => { + const entry = await restrictedController.addNewKeyring( + KeyringTypes.simple, + ); + + expect(entry.keyringV2).toBeDefined(); + }); + }); + }); + it('appears immediately in restrictedController.keyrings', async () => { const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; stubKeyringClassWithAccount(MockKeyring, mockAddress); From bd7f224b6a8cb7fa1595948b2fa9a553b42c70fa Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 9 Apr 2026 14:37:44 +0200 Subject: [PATCH 20/28] feat(snap-account-service): add SnapAccountService --- README.md | 2 + packages/snap-account-service/CHANGELOG.md | 10 +++ packages/snap-account-service/LICENSE | 20 +++++ packages/snap-account-service/README.md | 15 ++++ packages/snap-account-service/jest.config.js | 26 ++++++ packages/snap-account-service/package.json | 73 +++++++++++++++++ .../src/SnapAccountService.test.ts | 76 ++++++++++++++++++ .../src/SnapAccountService.ts | 80 +++++++++++++++++++ packages/snap-account-service/src/index.ts | 6 ++ .../snap-account-service/tsconfig.build.json | 12 +++ packages/snap-account-service/tsconfig.json | 10 +++ packages/snap-account-service/typedoc.json | 7 ++ tsconfig.build.json | 3 + tsconfig.json | 3 + yarn.lock | 20 ++++- 15 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 packages/snap-account-service/CHANGELOG.md create mode 100644 packages/snap-account-service/LICENSE create mode 100644 packages/snap-account-service/README.md create mode 100644 packages/snap-account-service/jest.config.js create mode 100644 packages/snap-account-service/package.json create mode 100644 packages/snap-account-service/src/SnapAccountService.test.ts create mode 100644 packages/snap-account-service/src/SnapAccountService.ts create mode 100644 packages/snap-account-service/src/index.ts create mode 100644 packages/snap-account-service/tsconfig.build.json create mode 100644 packages/snap-account-service/tsconfig.json create mode 100644 packages/snap-account-service/typedoc.json diff --git a/README.md b/README.md index 2d6154989be..7ba46ec1c14 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/selected-network-controller`](packages/selected-network-controller) - [`@metamask/shield-controller`](packages/shield-controller) - [`@metamask/signature-controller`](packages/signature-controller) +- [`@metamask/snap-account-service`](packages/snap-account-service) - [`@metamask/social-controllers`](packages/social-controllers) - [`@metamask/storage-service`](packages/storage-service) - [`@metamask/subscription-controller`](packages/subscription-controller) @@ -177,6 +178,7 @@ linkStyle default opacity:0.5 selected_network_controller(["@metamask/selected-network-controller"]); shield_controller(["@metamask/shield-controller"]); signature_controller(["@metamask/signature-controller"]); + snap_account_service(["@metamask/snap-account-service"]); social_controllers(["@metamask/social-controllers"]); storage_service(["@metamask/storage-service"]); subscription_controller(["@metamask/subscription-controller"]); diff --git a/packages/snap-account-service/CHANGELOG.md b/packages/snap-account-service/CHANGELOG.md new file mode 100644 index 00000000000..b518709c7b8 --- /dev/null +++ b/packages/snap-account-service/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/snap-account-service/LICENSE b/packages/snap-account-service/LICENSE new file mode 100644 index 00000000000..c8a0ff6be3a --- /dev/null +++ b/packages/snap-account-service/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/snap-account-service/README.md b/packages/snap-account-service/README.md new file mode 100644 index 00000000000..e7b5f9091ce --- /dev/null +++ b/packages/snap-account-service/README.md @@ -0,0 +1,15 @@ +# `@metamask/snap-account-service` + +Service for Account Management Snaps + +## Installation + +`yarn add @metamask/snap-account-service` + +or + +`npm install @metamask/snap-account-service` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/snap-account-service/jest.config.js b/packages/snap-account-service/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/snap-account-service/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/snap-account-service/package.json b/packages/snap-account-service/package.json new file mode 100644 index 00000000000..d81d1ad0955 --- /dev/null +++ b/packages/snap-account-service/package.json @@ -0,0 +1,73 @@ +{ + "name": "@metamask/snap-account-service", + "version": "0.0.0", + "description": "Service for Account Management Snaps", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/snap-account-service#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/snap-account-service", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/snap-account-service", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "dependencies": { + "@metamask/messenger": "workspace:^" + } +} diff --git a/packages/snap-account-service/src/SnapAccountService.test.ts b/packages/snap-account-service/src/SnapAccountService.test.ts new file mode 100644 index 00000000000..01e78617f16 --- /dev/null +++ b/packages/snap-account-service/src/SnapAccountService.test.ts @@ -0,0 +1,76 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import type { SnapAccountServiceMessenger } from './SnapAccountService'; +import { SnapAccountService } from './SnapAccountService'; + +describe('SnapAccountService', () => { + describe('init', () => { + it('resolves without throwing', async () => { + const { service } = createService(); + + await expect(service.init()).resolves.toBeUndefined(); + }); + }); +}); + +/** + * The type of the messenger populated with all external actions and events + * required by the service under test. + */ +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * Constructs the root messenger for the service under test. + * + * @returns The root messenger. + */ +function createRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the service under test. + * + * @param rootMessenger - The root messenger. + * @returns The service-specific messenger. + */ +function createServiceMessenger( + rootMessenger: RootMessenger, +): SnapAccountServiceMessenger { + return new Messenger({ + namespace: 'SnapAccountService', + parent: rootMessenger, + }); +} + +/** + * Constructs the service under test with sensible defaults. + * + * @param args - The arguments to this function. + * @param args.options - The options that the service constructor takes. + * @returns The new service, root messenger, and service messenger. + */ +function createService({ + options = {}, +}: { + options?: Partial[0]>; +} = {}): { + service: SnapAccountService; + rootMessenger: RootMessenger; + messenger: SnapAccountServiceMessenger; +} { + const rootMessenger = createRootMessenger(); + const messenger = createServiceMessenger(rootMessenger); + const service = new SnapAccountService({ messenger, ...options }); + + return { service, rootMessenger, messenger }; +} diff --git a/packages/snap-account-service/src/SnapAccountService.ts b/packages/snap-account-service/src/SnapAccountService.ts new file mode 100644 index 00000000000..ad1be018ff3 --- /dev/null +++ b/packages/snap-account-service/src/SnapAccountService.ts @@ -0,0 +1,80 @@ +import type { Messenger } from '@metamask/messenger'; + +/** + * The name of the {@link SnapAccountService}, used to namespace the service's + * actions and events. + */ +export const serviceName = 'SnapAccountService'; + +// === MESSENGER === + +/** + * All of the methods within {@link SnapAccountService} that are exposed via + * the messenger. + */ +const MESSENGER_EXPOSED_METHODS = [] as const; + +/** + * Actions that {@link SnapAccountService} exposes to other consumers. + */ +export type SnapAccountServiceActions = never; + +/** + * Actions from other messengers that {@link SnapAccountService} calls. + */ +type AllowedActions = never; + +/** + * Events that {@link SnapAccountService} exposes to other consumers. + */ +export type SnapAccountServiceEvents = never; + +/** + * Events from other messengers that {@link SnapAccountService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link SnapAccountService}. + */ +export type SnapAccountServiceMessenger = Messenger< + typeof serviceName, + SnapAccountServiceActions | AllowedActions, + SnapAccountServiceEvents | AllowedEvents +>; + +/** + * Service responsible for managing account management snaps. + */ +export class SnapAccountService { + /** + * The name of the service. + */ + readonly name: typeof serviceName; + + readonly #messenger: SnapAccountServiceMessenger; + + /** + * Constructs a new {@link SnapAccountService}. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this service. + */ + constructor({ messenger }: { messenger: SnapAccountServiceMessenger }) { + this.name = serviceName; + this.#messenger = messenger; + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Initializes the snap account service. + */ + async init(): Promise { + // TODO: Add initialization logic here. + } +} diff --git a/packages/snap-account-service/src/index.ts b/packages/snap-account-service/src/index.ts new file mode 100644 index 00000000000..dabef0507ff --- /dev/null +++ b/packages/snap-account-service/src/index.ts @@ -0,0 +1,6 @@ +export { SnapAccountService } from './SnapAccountService'; +export type { + SnapAccountServiceActions, + SnapAccountServiceEvents, + SnapAccountServiceMessenger, +} from './SnapAccountService'; diff --git a/packages/snap-account-service/tsconfig.build.json b/packages/snap-account-service/tsconfig.build.json new file mode 100644 index 00000000000..fa04f31e471 --- /dev/null +++ b/packages/snap-account-service/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/snap-account-service/tsconfig.json b/packages/snap-account-service/tsconfig.json new file mode 100644 index 00000000000..c0a1b5c06b5 --- /dev/null +++ b/packages/snap-account-service/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../messenger" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/snap-account-service/typedoc.json b/packages/snap-account-service/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/snap-account-service/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/tsconfig.build.json b/tsconfig.build.json index b02f3a882f5..8f378cdfb1b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -220,6 +220,9 @@ { "path": "./packages/signature-controller/tsconfig.build.json" }, + { + "path": "./packages/snap-account-service/tsconfig.build.json" + }, { "path": "./packages/social-controllers/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 7eb3aadc457..75ee50ee048 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -212,6 +212,9 @@ { "path": "./packages/signature-controller" }, + { + "path": "./packages/snap-account-service" + }, { "path": "./packages/social-controllers" }, diff --git a/yarn.lock b/yarn.lock index 36cdc5e7520..2fe855d8736 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4390,7 +4390,7 @@ __metadata: languageName: node linkType: hard -"@metamask/messenger@npm:^1.1.1, @metamask/messenger@workspace:packages/messenger": +"@metamask/messenger@npm:^1.1.1, @metamask/messenger@workspace:^, @metamask/messenger@workspace:packages/messenger": version: 0.0.0-use.local resolution: "@metamask/messenger@workspace:packages/messenger" dependencies: @@ -5293,6 +5293,24 @@ __metadata: languageName: node linkType: hard +"@metamask/snap-account-service@workspace:packages/snap-account-service": + version: 0.0.0-use.local + resolution: "@metamask/snap-account-service@workspace:packages/snap-account-service" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/messenger": "workspace:^" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/snaps-controllers@npm:^19.0.0": version: 19.0.0 resolution: "@metamask/snaps-controllers@npm:19.0.0" From c086fab41d4b2b196d9e7416169488c900e82b04 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 9 Apr 2026 15:22:41 +0200 Subject: [PATCH 21/28] chore: update CODEOWNERS --- .github/CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7da370ce3fe..830e5951fbf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,6 +13,7 @@ /packages/account-tree-controller @MetaMask/accounts-engineers /packages/profile-sync-controller @MetaMask/accounts-engineers /packages/money-account-controller @MetaMask/accounts-engineers +/packages/snap-account-service @MetaMask/accounts-engineers ## Assets Team /packages/assets-controllers @MetaMask/metamask-assets @@ -226,3 +227,5 @@ /packages/social-controllers/CHANGELOG.md @MetaMask/social-ai @MetaMask/core-platform /packages/money-account-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform /packages/money-account-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/snap-account-service/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/snap-account-service/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform From 5718119e00f3360990ef5d61ab08d3cff7dc1f6a Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 9 Apr 2026 15:23:50 +0200 Subject: [PATCH 22/28] chore: fix package.json --- packages/snap-account-service/package.json | 2 +- yarn.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/snap-account-service/package.json b/packages/snap-account-service/package.json index d81d1ad0955..ac784b16a7b 100644 --- a/packages/snap-account-service/package.json +++ b/packages/snap-account-service/package.json @@ -68,6 +68,6 @@ "registry": "https://registry.npmjs.org/" }, "dependencies": { - "@metamask/messenger": "workspace:^" + "@metamask/messenger": "^1.1.1" } } diff --git a/yarn.lock b/yarn.lock index 2fe855d8736..610be8184e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4390,7 +4390,7 @@ __metadata: languageName: node linkType: hard -"@metamask/messenger@npm:^1.1.1, @metamask/messenger@workspace:^, @metamask/messenger@workspace:packages/messenger": +"@metamask/messenger@npm:^1.1.1, @metamask/messenger@workspace:packages/messenger": version: 0.0.0-use.local resolution: "@metamask/messenger@workspace:packages/messenger" dependencies: @@ -5298,7 +5298,7 @@ __metadata: resolution: "@metamask/snap-account-service@workspace:packages/snap-account-service" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/messenger": "workspace:^" + "@metamask/messenger": "npm:^1.1.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" From 7218640f52bc68fc7a58972a25cad2cf0ad58f6d Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 9 Apr 2026 15:24:52 +0200 Subject: [PATCH 23/28] chore: changelog --- packages/snap-account-service/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/snap-account-service/CHANGELOG.md b/packages/snap-account-service/CHANGELOG.md index b518709c7b8..d52277b11f8 100644 --- a/packages/snap-account-service/CHANGELOG.md +++ b/packages/snap-account-service/CHANGELOG.md @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `SnapAccountService` ([#8414](https://github.com/MetaMask/core/pull/8414)) + [Unreleased]: https://github.com/MetaMask/core/ From 502c45847a713e14e2bb4ec0f34287d4f3a630c0 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 9 Apr 2026 15:40:17 +0200 Subject: [PATCH 24/28] chore: update readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7ba46ec1c14..3254d26d2b7 100644 --- a/README.md +++ b/README.md @@ -476,6 +476,7 @@ linkStyle default opacity:0.5 signature_controller --> logging_controller; signature_controller --> messenger; signature_controller --> network_controller; + snap_account_service --> messenger; social_controllers --> base_controller; social_controllers --> base_data_service; social_controllers --> controller_utils; From f27af12779be402e71a5a4d55e3e53f03c172b9d Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 9 Apr 2026 15:49:09 +0200 Subject: [PATCH 25/28] chore: update teams.json --- teams.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/teams.json b/teams.json index b2648bc9e8d..71291ce8015 100644 --- a/teams.json +++ b/teams.json @@ -75,5 +75,6 @@ "metamask/remote-feature-flag-controller": "team-extension-platform,team-mobile-platform", "metamask/storage-service": "team-extension-platform,team-mobile-platform", "metamask/config-registry-controller": "team-networks", - "metamask/money-account-controller": "team-accounts-framework" + "metamask/money-account-controller": "team-accounts-framework", + "metamask/snap-account-service": "team-accounts-framework" } From 3979b2848ba77e1a0626f4c6bbe04b66e74436fd Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 9 Apr 2026 15:57:50 +0200 Subject: [PATCH 26/28] chore: prettier --- packages/snap-account-service/package.json | 6 +++--- packages/snap-account-service/tsconfig.build.json | 4 +--- packages/snap-account-service/tsconfig.json | 4 +--- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/snap-account-service/package.json b/packages/snap-account-service/package.json index ac784b16a7b..bfded021346 100644 --- a/packages/snap-account-service/package.json +++ b/packages/snap-account-service/package.json @@ -48,6 +48,9 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "@metamask/messenger": "^1.1.1" + }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", @@ -66,8 +69,5 @@ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" - }, - "dependencies": { - "@metamask/messenger": "^1.1.1" } } diff --git a/packages/snap-account-service/tsconfig.build.json b/packages/snap-account-service/tsconfig.build.json index fa04f31e471..57f3ffc0f9b 100644 --- a/packages/snap-account-service/tsconfig.build.json +++ b/packages/snap-account-service/tsconfig.build.json @@ -5,8 +5,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [ - { "path": "../messenger/tsconfig.build.json" } - ], + "references": [{ "path": "../messenger/tsconfig.build.json" }], "include": ["../../types", "./src"] } diff --git a/packages/snap-account-service/tsconfig.json b/packages/snap-account-service/tsconfig.json index c0a1b5c06b5..77e4d580465 100644 --- a/packages/snap-account-service/tsconfig.json +++ b/packages/snap-account-service/tsconfig.json @@ -3,8 +3,6 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [ - { "path": "../messenger" } - ], + "references": [{ "path": "../messenger" }], "include": ["../../types", "./src"] } From 6e1c4fa7ae2f78dd09bc77f86a7c0f145133ae74 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 9 Apr 2026 17:26:39 +0200 Subject: [PATCH 27/28] test: lint test --- packages/snap-account-service/src/SnapAccountService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/snap-account-service/src/SnapAccountService.test.ts b/packages/snap-account-service/src/SnapAccountService.test.ts index 01e78617f16..641e00d7f62 100644 --- a/packages/snap-account-service/src/SnapAccountService.test.ts +++ b/packages/snap-account-service/src/SnapAccountService.test.ts @@ -13,7 +13,7 @@ describe('SnapAccountService', () => { it('resolves without throwing', async () => { const { service } = createService(); - await expect(service.init()).resolves.toBeUndefined(); + expect(await service.init()).toBeUndefined(); }); }); }); From 2963d9869b22d58d2dde59bb35f09c35a926cf2c Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 13 Apr 2026 14:13:41 +0200 Subject: [PATCH 28/28] chore: cosmetic --- .../src/SnapAccountService.test.ts | 30 +++++++++---------- .../src/SnapAccountService.ts | 2 -- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/snap-account-service/src/SnapAccountService.test.ts b/packages/snap-account-service/src/SnapAccountService.test.ts index 641e00d7f62..84753fe796c 100644 --- a/packages/snap-account-service/src/SnapAccountService.test.ts +++ b/packages/snap-account-service/src/SnapAccountService.test.ts @@ -8,16 +8,6 @@ import type { import type { SnapAccountServiceMessenger } from './SnapAccountService'; import { SnapAccountService } from './SnapAccountService'; -describe('SnapAccountService', () => { - describe('init', () => { - it('resolves without throwing', async () => { - const { service } = createService(); - - expect(await service.init()).toBeUndefined(); - }); - }); -}); - /** * The type of the messenger populated with all external actions and events * required by the service under test. @@ -33,7 +23,7 @@ type RootMessenger = Messenger< * * @returns The root messenger. */ -function createRootMessenger(): RootMessenger { +function getRootMessenger(): RootMessenger { return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); } @@ -43,7 +33,7 @@ function createRootMessenger(): RootMessenger { * @param rootMessenger - The root messenger. * @returns The service-specific messenger. */ -function createServiceMessenger( +function getMessenger( rootMessenger: RootMessenger, ): SnapAccountServiceMessenger { return new Messenger({ @@ -59,7 +49,7 @@ function createServiceMessenger( * @param args.options - The options that the service constructor takes. * @returns The new service, root messenger, and service messenger. */ -function createService({ +function setup({ options = {}, }: { options?: Partial[0]>; @@ -68,9 +58,19 @@ function createService({ rootMessenger: RootMessenger; messenger: SnapAccountServiceMessenger; } { - const rootMessenger = createRootMessenger(); - const messenger = createServiceMessenger(rootMessenger); + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); const service = new SnapAccountService({ messenger, ...options }); return { service, rootMessenger, messenger }; } + +describe('SnapAccountService', () => { + describe('init', () => { + it('resolves without throwing', async () => { + const { service } = setup(); + + expect(await service.init()).toBeUndefined(); + }); + }); +}); diff --git a/packages/snap-account-service/src/SnapAccountService.ts b/packages/snap-account-service/src/SnapAccountService.ts index ad1be018ff3..5a4e412aca4 100644 --- a/packages/snap-account-service/src/SnapAccountService.ts +++ b/packages/snap-account-service/src/SnapAccountService.ts @@ -6,8 +6,6 @@ import type { Messenger } from '@metamask/messenger'; */ export const serviceName = 'SnapAccountService'; -// === MESSENGER === - /** * All of the methods within {@link SnapAccountService} that are exposed via * the messenger.