Skip to content

Commit a5465bb

Browse files
ccharlymikesposito
andauthored
perf(keyring-controller): do not fire unnecessary :stageChange in withKeyring (#5732)
## Explanation We were always calling `#updateVault` when using `withKeyring` even when the keyring was not mutated. This PR now compare the states after the operation execution and see if the keyring got updated or not and decide to call `#updateVault` only if needed. Testing PR: - MetaMask/metamask-extension#32414 ## References N/A ## Changelog N/A ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Michele Esposito <[email protected]>
1 parent 1e41ed5 commit a5465bb

File tree

5 files changed

+137
-12
lines changed

5 files changed

+137
-12
lines changed

packages/keyring-controller/CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- Prevent emitting `:stateChange` from `withKeyring` unnecessarily ([#5732](https://github.com/MetaMask/core/pull/5732))
13+
1014
## [21.0.5]
1115

1216
### Changed

packages/keyring-controller/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"async-mutex": "^0.5.0",
6161
"ethereumjs-wallet": "^1.0.1",
6262
"immer": "^9.0.6",
63+
"lodash": "^4.17.21",
6364
"ulid": "^2.3.0"
6465
},
6566
"devDependencies": {

packages/keyring-controller/src/KeyringController.test.ts

+92-7
Original file line numberDiff line numberDiff line change
@@ -262,14 +262,26 @@ describe('KeyringController', () => {
262262
});
263263

264264
it('should throw error if the account is duplicated', async () => {
265-
jest
266-
.spyOn(HdKeyring.prototype, 'addAccounts')
267-
.mockResolvedValue(['0x123']);
268-
jest.spyOn(HdKeyring.prototype, 'getAccounts').mockReturnValue(['0x123']);
265+
const mockAddress = '0x123';
266+
const addAccountsSpy = jest.spyOn(HdKeyring.prototype, 'addAccounts');
267+
const getAccountsSpy = jest.spyOn(HdKeyring.prototype, 'getAccounts');
268+
const serializeSpy = jest.spyOn(HdKeyring.prototype, 'serialize');
269+
270+
addAccountsSpy.mockResolvedValue([mockAddress]);
271+
getAccountsSpy.mockReturnValue([mockAddress]);
269272
await withController(async ({ controller }) => {
270-
jest
271-
.spyOn(HdKeyring.prototype, 'getAccounts')
272-
.mockReturnValue(['0x123', '0x123']);
273+
getAccountsSpy.mockReturnValue([mockAddress, mockAddress]);
274+
serializeSpy
275+
.mockResolvedValueOnce({
276+
mnemonic: '',
277+
numberOfAccounts: 1,
278+
hdPath: "m/44'/60'/0'/0",
279+
})
280+
.mockResolvedValueOnce({
281+
mnemonic: '',
282+
numberOfAccounts: 2,
283+
hdPath: "m/44'/60'/0'/0",
284+
});
273285
await expect(controller.addNewAccount()).rejects.toThrow(
274286
KeyringControllerError.DuplicatedAccount,
275287
);
@@ -315,6 +327,11 @@ describe('KeyringController', () => {
315327
MockShallowGetAccountsKeyring.type,
316328
)[0] as EthKeyring;
317329

330+
jest
331+
.spyOn(mockKeyring, 'serialize')
332+
.mockResolvedValueOnce({ numberOfAccounts: 1 })
333+
.mockResolvedValueOnce({ numberOfAccounts: 2 });
334+
318335
const addedAccountAddress =
319336
await controller.addNewAccountForKeyring(mockKeyring);
320337

@@ -3115,6 +3132,74 @@ describe('KeyringController', () => {
31153132
},
31163133
);
31173134
});
3135+
3136+
it('should update the vault if the keyring is being updated', async () => {
3137+
const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4';
3138+
stubKeyringClassWithAccount(MockKeyring, mockAddress);
3139+
await withController(
3140+
{ keyringBuilders: [keyringBuilderFactory(MockKeyring)] },
3141+
async ({ controller, messenger }) => {
3142+
const selector = { type: MockKeyring.type };
3143+
3144+
await controller.addNewKeyring(MockKeyring.type);
3145+
const serializeSpy = jest.spyOn(
3146+
MockKeyring.prototype,
3147+
'serialize',
3148+
);
3149+
serializeSpy.mockResolvedValueOnce({
3150+
foo: 'bar', // Initial keyring state.
3151+
});
3152+
3153+
const mockStateChange = jest.fn();
3154+
messenger.subscribe(
3155+
'KeyringController:stateChange',
3156+
mockStateChange,
3157+
);
3158+
3159+
await controller.withKeyring(selector, async () => {
3160+
serializeSpy.mockResolvedValueOnce({
3161+
foo: 'zzz', // Mock keyring state change.
3162+
});
3163+
});
3164+
3165+
expect(mockStateChange).toHaveBeenCalled();
3166+
},
3167+
);
3168+
});
3169+
3170+
it('should not update the vault if the keyring has not been updated', async () => {
3171+
const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4';
3172+
stubKeyringClassWithAccount(MockKeyring, mockAddress);
3173+
await withController(
3174+
{
3175+
keyringBuilders: [keyringBuilderFactory(MockKeyring)],
3176+
},
3177+
async ({ controller, messenger }) => {
3178+
const selector = { type: MockKeyring.type };
3179+
3180+
await controller.addNewKeyring(MockKeyring.type);
3181+
const serializeSpy = jest.spyOn(
3182+
MockKeyring.prototype,
3183+
'serialize',
3184+
);
3185+
serializeSpy.mockResolvedValue({
3186+
foo: 'bar', // Initial keyring state.
3187+
});
3188+
3189+
const mockStateChange = jest.fn();
3190+
messenger.subscribe(
3191+
'KeyringController:stateChange',
3192+
mockStateChange,
3193+
);
3194+
3195+
await controller.withKeyring(selector, async () => {
3196+
// No-op, keyring state won't be updated.
3197+
});
3198+
3199+
expect(mockStateChange).not.toHaveBeenCalled();
3200+
},
3201+
);
3202+
});
31183203
});
31193204
});
31203205

packages/keyring-controller/src/KeyringController.ts

+39-5
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { Mutex } from 'async-mutex';
3737
import type { MutexInterface } from 'async-mutex';
3838
import Wallet, { thirdparty as importers } from 'ethereumjs-wallet';
3939
import type { Patch } from 'immer';
40+
import { isEqual } from 'lodash';
4041
// When generating a ULID within the same millisecond, monotonicFactory provides some guarantees regarding sort order.
4142
import { ulid } from 'ulid';
4243

@@ -320,6 +321,15 @@ export type SerializedKeyring = {
320321
data: Json;
321322
};
322323

324+
/**
325+
* State/data that can be updated during a `withKeyring` operation.
326+
*/
327+
type SessionState = {
328+
keyrings: SerializedKeyring[];
329+
keyringsMetadata: KeyringMetadata[];
330+
password?: string;
331+
};
332+
323333
/**
324334
* A generic encryptor interface that supports encrypting and decrypting
325335
* serializable data with a password.
@@ -1027,8 +1037,12 @@ export class KeyringController extends BaseController<
10271037
* operation completes.
10281038
*/
10291039
async persistAllKeyrings(): Promise<boolean> {
1030-
this.#assertIsUnlocked();
1031-
return this.#persistOrRollback(async () => true);
1040+
return this.#withRollback(async () => {
1041+
this.#assertIsUnlocked();
1042+
1043+
await this.#updateVault();
1044+
return true;
1045+
});
10321046
}
10331047

10341048
/**
@@ -1399,6 +1413,7 @@ export class KeyringController extends BaseController<
13991413
*/
14001414
changePassword(password: string): Promise<void> {
14011415
this.#assertIsUnlocked();
1416+
14021417
return this.#persistOrRollback(async () => {
14031418
assertIsValidPassword(password);
14041419

@@ -2158,6 +2173,20 @@ export class KeyringController extends BaseController<
21582173
return serializedKeyrings;
21592174
}
21602175

2176+
/**
2177+
* Get a snapshot of session data held by class variables.
2178+
*
2179+
* @returns An object with serialized keyrings, keyrings metadata,
2180+
* and the user password.
2181+
*/
2182+
async #getSessionState(): Promise<SessionState> {
2183+
return {
2184+
keyrings: await this.#getSerializedKeyrings(),
2185+
keyringsMetadata: this.#keyringsMetadata.slice(), // Force copy.
2186+
password: this.#password,
2187+
};
2188+
}
2189+
21612190
/**
21622191
* Restore a serialized keyrings array.
21632192
*
@@ -2635,7 +2664,7 @@ export class KeyringController extends BaseController<
26352664

26362665
/**
26372666
* Execute the given function after acquiring the controller lock
2638-
* and save the keyrings to state after it, or rollback to their
2667+
* and save the vault to state after it (only if needed), or rollback to their
26392668
* previous state in case of error.
26402669
*
26412670
* @param callback - The function to execute.
@@ -2645,9 +2674,14 @@ export class KeyringController extends BaseController<
26452674
callback: MutuallyExclusiveCallback<Result>,
26462675
): Promise<Result> {
26472676
return this.#withRollback(async ({ releaseLock }) => {
2677+
const oldState = await this.#getSessionState();
26482678
const callbackResult = await callback({ releaseLock });
2649-
// State is committed only if the operation is successful
2650-
await this.#updateVault();
2679+
const newState = await this.#getSessionState();
2680+
2681+
// State is committed only if the operation is successful and need to trigger a vault update.
2682+
if (!isEqual(oldState, newState)) {
2683+
await this.#updateVault();
2684+
}
26512685

26522686
return callbackResult;
26532687
});

yarn.lock

+1
Original file line numberDiff line numberDiff line change
@@ -3555,6 +3555,7 @@ __metadata:
35553555
immer: "npm:^9.0.6"
35563556
jest: "npm:^27.5.1"
35573557
jest-environment-node: "npm:^27.5.1"
3558+
lodash: "npm:^4.17.21"
35583559
sinon: "npm:^9.2.4"
35593560
ts-jest: "npm:^27.1.4"
35603561
typedoc: "npm:^0.24.8"

0 commit comments

Comments
 (0)