Skip to content

Commit 05b39bc

Browse files
authored
fix(multichain-account-service): prevent creating EVM account during discovery (#6650)
## Explanation Derive next address instead of creating the account entirely (which triggers `:accountAdded` and adds side-effects for this temporary account). ## References N/A ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] 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
1 parent 22a1921 commit 05b39bc

File tree

5 files changed

+121
-59
lines changed

5 files changed

+121
-59
lines changed

packages/multichain-account-service/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Add custom account provider configs ([#6624](https://github.com/MetaMask/core/pull/6624))
1414
- This new config can be set by the clients to update discovery timeout/retry values.
1515

16+
### Fixed
17+
18+
- No longer create temporary EVM account during discovery ([#6650](https://github.com/MetaMask/core/pull/6650))
19+
- We used to create the EVM account and remove it if there was no activity for that account. Now we're just deriving the next address directly, which avoids state mutation.
20+
- This prevents `:accountAdded` event from being published, which also prevents account-tree and multichain-account service updates.
21+
- Backup & sync will no longer synchronize this temporary account group, which was causing a bug that persisted it on the user profile and left it permanently.
22+
1623
## [0.9.0]
1724

1825
### Added

packages/multichain-account-service/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
4848
},
4949
"dependencies": {
50+
"@ethereumjs/util": "^9.1.0",
5051
"@metamask/base-controller": "^8.4.0",
5152
"@metamask/eth-snap-keyring": "^17.0.0",
5253
"@metamask/key-tree": "^10.1.1",

packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts

Lines changed: 62 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable jsdoc/require-jsdoc */
2+
import { publicToAddress } from '@ethereumjs/util';
13
import type { Messenger } from '@metamask/base-controller';
24
import { type KeyringMetadata } from '@metamask/keyring-controller';
35
import type {
@@ -8,6 +10,8 @@ import type {
810
AutoManagedNetworkClient,
911
CustomNetworkClientConfiguration,
1012
} from '@metamask/network-controller';
13+
import type { Hex } from '@metamask/utils';
14+
import { createBytes } from '@metamask/utils';
1115

1216
import { EvmAccountProvider } from './EvmAccountProvider';
1317
import { TimeoutError } from './utils';
@@ -26,6 +30,32 @@ import type {
2630
MultichainAccountServiceEvents,
2731
} from '../types';
2832

33+
jest.mock('@ethereumjs/util', () => ({
34+
publicToAddress: jest.fn(),
35+
}));
36+
37+
function mockNextDiscoveryAddress(address: string) {
38+
jest.mocked(publicToAddress).mockReturnValue(createBytes(address as Hex));
39+
}
40+
41+
function mockNextDiscoveryAddressOnce(address: string) {
42+
jest.mocked(publicToAddress).mockReturnValueOnce(createBytes(address as Hex));
43+
}
44+
45+
type MockHdKey = {
46+
deriveChild: jest.Mock;
47+
};
48+
49+
function mockHdKey(): MockHdKey {
50+
return {
51+
deriveChild: jest.fn().mockImplementation(() => {
52+
return {
53+
publicKey: new Uint8Array(65),
54+
};
55+
}),
56+
};
57+
}
58+
2959
class MockEthKeyring implements EthKeyring {
3060
readonly type = 'MockEthKeyring';
3161

@@ -36,8 +66,11 @@ class MockEthKeyring implements EthKeyring {
3666

3767
readonly accounts: InternalAccount[];
3868

69+
readonly root: MockHdKey;
70+
3971
constructor(accounts: InternalAccount[]) {
4072
this.accounts = accounts;
73+
this.root = mockHdKey();
4174
}
4275

4376
async serialize() {
@@ -85,17 +118,23 @@ class MockEthKeyring implements EthKeyring {
85118
* @param options - Configuration options for setup.
86119
* @param options.messenger - An optional messenger instance to use. Defaults to a new Messenger.
87120
* @param options.accounts - List of accounts to use.
121+
* @param options.discovery - Discovery options.
122+
* @param options.discovery.transactionCount - Transaction count (use '0x0' to stop the discovery).
88123
* @returns An object containing the controller instance and the messenger.
89124
*/
90125
function setup({
91126
messenger = getRootMessenger(),
92127
accounts = [],
128+
discovery,
93129
}: {
94130
messenger?: Messenger<
95131
MultichainAccountServiceActions | AllowedActions,
96132
MultichainAccountServiceEvents | AllowedEvents
97133
>;
98134
accounts?: InternalAccount[];
135+
discovery?: {
136+
transactionCount: string;
137+
};
99138
} = {}): {
100139
provider: EvmAccountProvider;
101140
messenger: Messenger<
@@ -123,7 +162,7 @@ function setup({
123162

124163
const mockProviderRequest = jest.fn().mockImplementation(({ method }) => {
125164
if (method === 'eth_getTransactionCount') {
126-
return '0x2';
165+
return discovery?.transactionCount ?? '0x2';
127166
}
128167
throw new Error(`Unknown method: ${method}`);
129168
});
@@ -156,6 +195,8 @@ function setup({
156195
},
157196
);
158197

198+
mockNextDiscoveryAddress('0x123');
199+
159200
const provider = new EvmAccountProvider(
160201
getMultichainAccountServiceMessenger(messenger),
161202
);
@@ -274,13 +315,17 @@ describe('EvmAccountProvider', () => {
274315
accounts: [],
275316
});
276317

318+
const account = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1)
319+
.withAddressSuffix('0')
320+
.get();
321+
277322
const expectedAccount = {
278-
...MockAccountBuilder.from(MOCK_HD_ACCOUNT_1)
279-
.withAddressSuffix('0')
280-
.get(),
323+
...account,
281324
id: expect.any(String),
282325
};
283326

327+
mockNextDiscoveryAddressOnce(account.address);
328+
284329
expect(
285330
await provider.discoverAccounts({
286331
entropySource: MOCK_HD_KEYRING_1.metadata.id,
@@ -291,42 +336,28 @@ describe('EvmAccountProvider', () => {
291336
expect(provider.getAccounts()).toStrictEqual([expectedAccount]);
292337
});
293338

294-
it('removes discovered account if no transaction history is found', async () => {
295-
const { provider, mocks } = setup({
296-
accounts: [MOCK_HD_ACCOUNT_1],
339+
it('stops discovery if there is no transaction activity', async () => {
340+
const { provider } = setup({
341+
accounts: [],
342+
discovery: {
343+
transactionCount: '0x0',
344+
},
297345
});
298346

299-
mocks.mockProviderRequest.mockReturnValue('0x0');
347+
const account = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1)
348+
.withAddressSuffix('0')
349+
.get();
350+
351+
mockNextDiscoveryAddressOnce(account.address);
300352

301353
expect(
302354
await provider.discoverAccounts({
303355
entropySource: MOCK_HD_KEYRING_1.metadata.id,
304-
groupIndex: 1,
356+
groupIndex: 0,
305357
}),
306358
).toStrictEqual([]);
307359

308-
await Promise.resolve();
309-
310-
expect(provider.getAccounts()).toStrictEqual([MOCK_HD_ACCOUNT_1]);
311-
});
312-
313-
it('removes discovered account if RPC request fails', async () => {
314-
const { provider, mocks } = setup({
315-
accounts: [MOCK_HD_ACCOUNT_1],
316-
});
317-
318-
mocks.mockProviderRequest.mockImplementation(() => {
319-
throw new Error('RPC request failed');
320-
});
321-
322-
await expect(
323-
provider.discoverAccounts({
324-
entropySource: MOCK_HD_KEYRING_1.metadata.id,
325-
groupIndex: 1,
326-
}),
327-
).rejects.toThrow('RPC request failed');
328-
329-
expect(provider.getAccounts()).toStrictEqual([MOCK_HD_ACCOUNT_1]);
360+
expect(provider.getAccounts()).toStrictEqual([]);
330361
});
331362

332363
it('retries RPC request up to 3 times if it fails and throws the last error', async () => {

packages/multichain-account-service/src/providers/EvmAccountProvider.ts

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { publicToAddress } from '@ethereumjs/util';
12
import type { Bip44Account } from '@metamask/account-api';
3+
import type { HdKeyring } from '@metamask/eth-hd-keyring';
24
import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api';
35
import { EthAccountType } from '@metamask/keyring-api';
46
import { KeyringTypes } from '@metamask/keyring-controller';
@@ -7,7 +9,7 @@ import type {
79
InternalAccount,
810
} from '@metamask/keyring-internal-api';
911
import type { Provider } from '@metamask/network-controller';
10-
import type { Hex } from '@metamask/utils';
12+
import { add0x, assert, bytesToHex, type Hex } from '@metamask/utils';
1113
import type { MultichainAccountServiceMessenger } from 'src/types';
1214

1315
import {
@@ -169,6 +171,36 @@ export class EvmAccountProvider extends BaseBip44AccountProvider {
169171
return parseInt(countHex, 16);
170172
}
171173

174+
async #getAddressFromGroupIndex({
175+
entropySource,
176+
groupIndex,
177+
}: {
178+
entropySource: EntropySourceId;
179+
groupIndex: number;
180+
}): Promise<Hex> {
181+
// NOTE: To avoid exposing this function at keyring level, we just re-use its internal state
182+
// and compute the derivation here.
183+
return await this.withKeyring<HdKeyring, Hex>(
184+
{ id: entropySource },
185+
async ({ keyring }) => {
186+
// If the account already exist, do not re-derive and just re-use that account.
187+
const existing = await keyring.getAccounts();
188+
if (groupIndex < existing.length) {
189+
return existing[groupIndex];
190+
}
191+
192+
// If not, then we just "peek" the next address to avoid creating the account.
193+
assert(keyring.root, 'Expected HD keyring.root to be set');
194+
const hdKey = keyring.root.deriveChild(groupIndex);
195+
assert(hdKey.publicKey, 'Expected public key to be set');
196+
197+
return add0x(
198+
bytesToHex(publicToAddress(hdKey.publicKey, true)).toLowerCase(),
199+
);
200+
},
201+
);
202+
}
203+
172204
/**
173205
* Discover and create accounts for the EVM provider.
174206
*
@@ -184,39 +216,29 @@ export class EvmAccountProvider extends BaseBip44AccountProvider {
184216
const provider = this.getEvmProvider();
185217
const { entropySource, groupIndex } = opts;
186218

187-
const [address, didCreate] = await this.#createAccount({
219+
const addressFromGroupIndex = await this.#getAddressFromGroupIndex({
188220
entropySource,
189221
groupIndex,
190222
});
191223

192-
// We don't want to remove the account if it's the first one.
193-
const shouldCleanup = didCreate && groupIndex !== 0;
194-
try {
195-
const count = await this.#getTransactionCount(provider, address);
196-
197-
if (count === 0 && shouldCleanup) {
198-
await this.withKeyring<EthKeyring>(
199-
{ id: entropySource },
200-
async ({ keyring }) => {
201-
keyring.removeAccount?.(address);
202-
},
203-
);
204-
return [];
205-
}
206-
} catch (error) {
207-
// If the RPC request fails and we just created this account for discovery,
208-
// remove it to avoid leaving a dangling account.
209-
if (shouldCleanup) {
210-
await this.withKeyring<EthKeyring>(
211-
{ id: entropySource },
212-
async ({ keyring }) => {
213-
keyring.removeAccount?.(address);
214-
},
215-
);
216-
}
217-
throw error;
224+
const count = await this.#getTransactionCount(
225+
provider,
226+
addressFromGroupIndex,
227+
);
228+
if (count === 0) {
229+
return [];
218230
}
219231

232+
// We have some activity on this address, we try to create the account.
233+
const [address] = await this.#createAccount({
234+
entropySource,
235+
groupIndex,
236+
});
237+
assert(
238+
addressFromGroupIndex === address,
239+
'Created account does not match address from group index.',
240+
);
241+
220242
const account = this.messenger.call(
221243
'AccountsController:getAccountByAddress',
222244
address,

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3836,6 +3836,7 @@ __metadata:
38363836
version: 0.0.0-use.local
38373837
resolution: "@metamask/multichain-account-service@workspace:packages/multichain-account-service"
38383838
dependencies:
3839+
"@ethereumjs/util": "npm:^9.1.0"
38393840
"@metamask/account-api": "npm:^0.12.0"
38403841
"@metamask/accounts-controller": "npm:^33.1.0"
38413842
"@metamask/auto-changelog": "npm:^3.4.4"

0 commit comments

Comments
 (0)