Skip to content

Commit db3a2cc

Browse files
committed
fix(multichain-account-service): prevent spurrious alignment logs + smaller map updates
1 parent 67c50d7 commit db3a2cc

File tree

5 files changed

+196
-13
lines changed

5 files changed

+196
-13
lines changed

packages/multichain-account-service/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Changed
1111

1212
- Bump `@metamask/accounts-controller` from `^36.0.0` to `^36.0.1` ([#7996](https://github.com/MetaMask/core/pull/7996))
13+
- Faster state change during alignment ([#8136](https://github.com/MetaMask/core/pull/8136))
14+
- We used to re-create the entire mapping of accounts and their providers during alignment, now we only update the accounts that actually changed or got removed.
15+
16+
### Fixed
17+
18+
- Prevent spurious logs during alignment ([#8136](https://github.com/MetaMask/core/pull/8136))
1319

1420
## [7.0.0]
1521

packages/multichain-account-service/jest.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ module.exports = merge(baseConfig, {
1414
// The display name when running multiple projects
1515
displayName,
1616

17+
// Exclude test helpers from coverage collection
18+
coveragePathIgnorePatterns: ['.*/src/tests/.*'],
19+
1720
// An object that configures minimum threshold enforcement for coverage results
1821
coverageThreshold: {
1922
global: {

packages/multichain-account-service/src/MultichainAccountGroup.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,24 @@ describe('MultichainAccount', () => {
283283
expect(providers[1].createAccounts).not.toHaveBeenCalled();
284284
});
285285

286+
it('removes accounts from the group when a provider no longer returns them during alignment', async () => {
287+
const groupIndex = 0;
288+
const { group, providers } = setup({
289+
groupIndex,
290+
accounts: [[MOCK_WALLET_1_EVM_ACCOUNT, MOCK_WALLET_1_SOL_ACCOUNT], []],
291+
});
292+
293+
// Provider 0 previously had both accounts but now only returns the EVM one.
294+
mockCreateAccountsOnce(providers[0], [MOCK_WALLET_1_EVM_ACCOUNT]);
295+
296+
await group.alignAccounts();
297+
298+
expect(group.getAccount(MOCK_WALLET_1_EVM_ACCOUNT.id)).toBe(
299+
MOCK_WALLET_1_EVM_ACCOUNT,
300+
);
301+
expect(group.getAccount(MOCK_WALLET_1_SOL_ACCOUNT.id)).toBeUndefined();
302+
});
303+
286304
it('removes accounts from the group when a provider is disabled', async () => {
287305
const groupIndex = 0;
288306
const { group, providers } = setup({

packages/multichain-account-service/src/MultichainAccountGroup.ts

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -267,33 +267,71 @@ export class MultichainAccountGroup<
267267
async alignAccounts(): Promise<void> {
268268
this.#log('Aligning accounts...');
269269

270-
this.#providerToAccounts.clear();
271-
this.#accountToProvider.clear();
272-
273270
const results = await Promise.allSettled(
274271
this.#providers.map(async (provider) => {
272+
const providerName = provider.getName();
273+
275274
try {
275+
// Existing (old) accounts for that provider (if any).
276+
const oldAccounts = new Set(
277+
this.#providerToAccounts.get(provider) ?? [],
278+
);
279+
280+
// Remove any previously tracked accounts if provider gets disabled.
281+
if (isAccountProviderWrapper(provider) && provider.isDisabled()) {
282+
this.#log(
283+
`Account provider "${providerName}" is disabled, skipping alignment...`,
284+
);
285+
286+
for (const accountId of oldAccounts) {
287+
this.#accountToProvider.delete(accountId);
288+
}
289+
this.#providerToAccounts.delete(provider);
290+
291+
return [];
292+
}
293+
294+
// We align accounts no matter what, we cannot guess if providers starts to support new
295+
// account types or scopes.
276296
const accounts = await provider.alignAccounts({
277297
entropySource: this.wallet.entropySource,
278298
groupIndex: this.groupIndex,
279299
});
280300

281-
const isDisabled =
282-
isAccountProviderWrapper(provider) && provider.isDisabled();
301+
// Compute a diff between the previously known accounts to see if some accounts got removed
302+
// or added during alignment.
303+
const newAccounts = new Set(accounts);
304+
for (const accountId of accounts) {
305+
// If we knew about this account before, it's not new nor removed.
306+
if (oldAccounts.has(accountId)) {
307+
oldAccounts.delete(accountId);
308+
newAccounts.delete(accountId);
309+
}
310+
}
283311

284-
if (isDisabled) {
312+
const hasRemovedAccounts = oldAccounts.size > 0;
313+
const hasAddedAccounts = newAccounts.size > 0;
314+
if (hasRemovedAccounts) {
285315
this.#log(
286-
`Account provider "${provider.getName()}" is disabled, skipping alignment...`,
316+
`Found ${oldAccounts.size} removed accounts for account provider "${providerName}", removing them from the group...`,
287317
);
288-
} else if (accounts.length > 0) {
318+
319+
for (const accountId of oldAccounts) {
320+
this.#accountToProvider.delete(accountId);
321+
}
322+
}
323+
if (hasAddedAccounts) {
289324
this.#log(
290-
`Found missing accounts for account provider "${provider.getName()}", creating them now...`,
325+
`Found ${newAccounts.size} new accounts for account provider "${providerName}", adding them to the group...`,
291326
);
292-
this.#providerToAccounts.set(provider, accounts);
293-
for (const accountId of accounts) {
327+
328+
for (const accountId of newAccounts) {
294329
this.#accountToProvider.set(accountId, provider);
295330
}
296331
}
332+
if (hasRemovedAccounts || hasAddedAccounts) {
333+
this.#providerToAccounts.set(provider, accounts);
334+
}
297335

298336
return accounts;
299337
} catch (error) {
@@ -302,11 +340,11 @@ export class MultichainAccountGroup<
302340
`${WARNING_PREFIX} ${error instanceof Error ? error.message : String(error)}`,
303341
);
304342
const sentryError = createSentryError(
305-
`Unable to align accounts with provider "${provider.getName()}"`,
343+
`Unable to align accounts with provider "${providerName}"`,
306344
error as Error,
307345
{
308346
groupIndex: this.groupIndex,
309-
provider: provider.getName(),
347+
provider: providerName,
310348
},
311349
);
312350
this.#messenger.captureException?.(sentryError);
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import type { Bip44Account } from '@metamask/account-api';
2+
import type {
3+
CreateAccountOptions,
4+
KeyringAccount,
5+
KeyringCapabilities,
6+
} from '@metamask/keyring-api';
7+
import type { InternalAccount } from '@metamask/keyring-internal-api';
8+
9+
import { AccountProviderWrapper } from './AccountProviderWrapper';
10+
import { BaseBip44AccountProvider } from './BaseBip44AccountProvider';
11+
import {
12+
getMultichainAccountServiceMessenger,
13+
getRootMessenger,
14+
MOCK_HD_KEYRING_1,
15+
MOCK_SOL_ACCOUNT_1,
16+
} from '../tests';
17+
import type { MultichainAccountServiceMessenger } from '../types';
18+
19+
class MockInnerProvider extends BaseBip44AccountProvider {
20+
readonly capabilities: KeyringCapabilities = {
21+
scopes: [],
22+
bip44: { deriveIndex: true },
23+
};
24+
25+
getName(): string {
26+
return 'MockInnerProvider';
27+
}
28+
29+
isAccountCompatible(_account: Bip44Account<KeyringAccount>): boolean {
30+
return true;
31+
}
32+
33+
async createAccounts(
34+
_options: CreateAccountOptions,
35+
): Promise<Bip44Account<KeyringAccount>[]> {
36+
return [];
37+
}
38+
39+
async discoverAccounts(_options: {
40+
entropySource: string;
41+
groupIndex: number;
42+
}): Promise<Bip44Account<KeyringAccount>[]> {
43+
return [];
44+
}
45+
46+
resyncAccounts(_accounts: Bip44Account<InternalAccount>[]): Promise<void> {
47+
return Promise.resolve();
48+
}
49+
}
50+
51+
function setup(): {
52+
wrapper: AccountProviderWrapper;
53+
innerProvider: MockInnerProvider;
54+
} {
55+
const messenger = getRootMessenger();
56+
const multichainMessenger: MultichainAccountServiceMessenger =
57+
getMultichainAccountServiceMessenger(messenger);
58+
59+
const innerProvider = new MockInnerProvider(multichainMessenger);
60+
const wrapper = new AccountProviderWrapper(multichainMessenger, innerProvider);
61+
62+
return { wrapper, innerProvider };
63+
}
64+
65+
describe('AccountProviderWrapper', () => {
66+
describe('alignAccounts', () => {
67+
it('returns empty array when disabled', async () => {
68+
const { wrapper } = setup();
69+
70+
wrapper.setEnabled(false);
71+
72+
const result = await wrapper.alignAccounts({
73+
entropySource: MOCK_HD_KEYRING_1.metadata.id,
74+
groupIndex: 0,
75+
});
76+
77+
expect(result).toStrictEqual([]);
78+
});
79+
80+
it('does not delegate to inner provider when disabled', async () => {
81+
const { wrapper, innerProvider } = setup();
82+
innerProvider.init([MOCK_SOL_ACCOUNT_1.id]);
83+
84+
const alignSpy = jest
85+
.spyOn(innerProvider, 'alignAccounts')
86+
.mockResolvedValue([MOCK_SOL_ACCOUNT_1.id]);
87+
88+
wrapper.setEnabled(false);
89+
90+
await wrapper.alignAccounts({
91+
entropySource: MOCK_HD_KEYRING_1.metadata.id,
92+
groupIndex: 0,
93+
});
94+
95+
expect(alignSpy).not.toHaveBeenCalled();
96+
});
97+
98+
it('delegates to inner provider when enabled', async () => {
99+
const { wrapper, innerProvider } = setup();
100+
innerProvider.init([MOCK_SOL_ACCOUNT_1.id]);
101+
102+
const alignSpy = jest
103+
.spyOn(innerProvider, 'alignAccounts')
104+
.mockResolvedValue([MOCK_SOL_ACCOUNT_1.id]);
105+
106+
const result = await wrapper.alignAccounts({
107+
entropySource: MOCK_HD_KEYRING_1.metadata.id,
108+
groupIndex: 0,
109+
});
110+
111+
expect(alignSpy).toHaveBeenCalledWith({
112+
entropySource: MOCK_HD_KEYRING_1.metadata.id,
113+
groupIndex: 0,
114+
});
115+
expect(result).toStrictEqual([MOCK_SOL_ACCOUNT_1.id]);
116+
});
117+
});
118+
});

0 commit comments

Comments
 (0)