Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7f78a9e
feat(keyring-controller): add withController for atomic operations ov…
ccharly Apr 9, 2026
6fe4801
chore: changelog
ccharly Apr 9, 2026
e43f88c
feat: expose withController as an action
ccharly Apr 9, 2026
9301d9c
test: add test for action
ccharly Apr 9, 2026
98dfffe
chore: lint
ccharly Apr 9, 2026
e54308a
refactor: better comments + add KeyringEntry type
ccharly Apr 9, 2026
04beb30
chore: docs
ccharly Apr 13, 2026
e6c9db5
chore: lint
ccharly Apr 13, 2026
cfa9cc2
refactor: use KeyringEntry in RestrictedController + export it
ccharly Apr 13, 2026
001d092
fix: fix addNewKeyring
ccharly Apr 13, 2026
5c291ea
fix: properly destroy v2 instances
ccharly Apr 13, 2026
02a9fac
fix: assert v2 instances
ccharly Apr 13, 2026
491434b
fix: missing export
ccharly Apr 13, 2026
019cc60
fix: re-use SelectedKeyring in withKeyring
ccharly Apr 13, 2026
559ca38
refactor: cosmetic (move withController block)
ccharly Apr 13, 2026
ba3dbf4
fix: check for removed keyrings unsafe access too
ccharly Apr 13, 2026
e60c978
refactor: cosmetic (move withController block)
ccharly Apr 13, 2026
21276f6
chore: changelog
ccharly Apr 13, 2026
d12e369
test: add test for v2 with RestrictedController.addNewKeyring
ccharly Apr 14, 2026
bd7f224
feat(snap-account-service): add SnapAccountService
ccharly Apr 9, 2026
c086fab
chore: update CODEOWNERS
ccharly Apr 9, 2026
5718119
chore: fix package.json
ccharly Apr 9, 2026
7218640
chore: changelog
ccharly Apr 9, 2026
502c458
chore: update readme
ccharly Apr 9, 2026
f27af12
chore: update teams.json
ccharly Apr 9, 2026
3979b28
chore: prettier
ccharly Apr 9, 2026
6e1c4fa
test: lint test
ccharly Apr 9, 2026
2963d98
chore: cosmetic
ccharly Apr 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"]);
Expand Down Expand Up @@ -474,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;
Expand Down
2 changes: 2 additions & 0 deletions packages/keyring-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,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.
*/
Expand All @@ -401,4 +419,5 @@ export type KeyringControllerMethodActions =
| KeyringControllerWithKeyringAction
| KeyringControllerWithKeyringUnsafeAction
| KeyringControllerWithKeyringV2Action
| KeyringControllerWithKeyringV2UnsafeAction;
| KeyringControllerWithKeyringV2UnsafeAction
| KeyringControllerWithControllerAction;
281 changes: 281 additions & 0 deletions packages/keyring-controller/src/KeyringController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4069,6 +4069,265 @@ 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,
);
});
});

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';
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('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);

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 }) => {
Expand Down Expand Up @@ -5026,6 +5285,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 = {
Expand Down
Loading
Loading