Skip to content

Commit 99d0aa2

Browse files
committed
feat(multichain-account-service): add Solana account batching
1 parent 16a70a6 commit 99d0aa2

File tree

8 files changed

+126
-51
lines changed

8 files changed

+126
-51
lines changed

packages/multichain-account-service/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
### Changed
1515

16+
- Optimize `SolAccountProvider.createAccounts` for range operations ([#8131](https://github.com/MetaMask/core/pull/8131))
17+
- Batch account creation with the new `SnapKeyring.createAccounts` method.
18+
- Significantly reduces lock acquisitions and API calls for batch operations.
1619
- Optimize `EvmAccountProvider.createAccounts` for range operations ([#7801](https://github.com/MetaMask/core/pull/7801))
1720
- Batch account creation with single a `withKeyring` call for entire range instead of one call per account.
1821
- Batch account creation with single `keyring.addAccounts` call.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ class MockBtcKeyring {
9898

9999
return account;
100100
});
101+
102+
createAccounts: SnapKeyring['createAccounts'] = jest.fn();
101103
}
102104
class MockBtcAccountProvider extends BtcAccountProvider {
103105
override async ensureCanUseSnapPlatform(): Promise<void> {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ const setup = ({
213213

214214
const keyring = {
215215
createAccount: jest.fn(),
216+
createAccounts: jest.fn(),
216217
removeAccount: jest.fn(),
217218
};
218219

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { createSentryError } from '../utils';
2222

2323
export type RestrictedSnapKeyring = {
2424
createAccount: (options: Record<string, Json>) => Promise<KeyringAccount>;
25+
createAccounts: (options: CreateAccountOptions) => Promise<KeyringAccount[]>;
2526
removeAccount: (address: string) => Promise<void>;
2627
};
2728

@@ -126,6 +127,10 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider {
126127
SnapKeyring['createAccount']
127128
>(async ({ keyring }) => keyring.createAccount.bind(keyring));
128129

130+
const createAccounts = await this.#withSnapKeyring<
131+
SnapKeyring['createAccounts']
132+
>(async ({ keyring }) => keyring.createAccounts.bind(keyring));
133+
129134
return {
130135
createAccount: async (options) =>
131136
// We use the "unguarded" account creation here (see explanation above).
@@ -134,6 +139,8 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider {
134139
displayConfirmation: false,
135140
setSelectedAccount: false,
136141
}),
142+
createAccounts: async (options) =>
143+
await createAccounts(this.snapId, options),
137144
removeAccount: async (address: string) =>
138145
// Though, when removing account, we can use the normal flow.
139146
await this.#withSnapKeyring(async ({ keyring }) => {

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

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { TraceName } from '../constants/traces';
1919
import {
2020
getMultichainAccountServiceMessenger,
2121
getRootMessenger,
22+
toGroupIndexRangeArray,
2223
MOCK_HD_ACCOUNT_1,
2324
MOCK_HD_KEYRING_1,
2425
MOCK_SOL_ACCOUNT_1,
@@ -82,6 +83,35 @@ class MockSolanaKeyring {
8283

8384
return account;
8485
});
86+
87+
createAccounts: SnapKeyring['createAccounts'] = jest
88+
.fn()
89+
.mockImplementation((_, options) => {
90+
const groupIndices =
91+
options.type === 'bip44:derive-index'
92+
? [options.groupIndex]
93+
: toGroupIndexRangeArray(options.range);
94+
95+
return groupIndices.map((groupIndex) => {
96+
const found = this.accounts.find(
97+
(account) =>
98+
isBip44Account(account) &&
99+
account.options.entropy.groupIndex === groupIndex,
100+
);
101+
102+
if (found) {
103+
return found; // Idempotent.
104+
}
105+
106+
const account = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1)
107+
.withUuid()
108+
.withAddressSuffix(`${groupIndex}`)
109+
.withGroupIndex(groupIndex)
110+
.get();
111+
this.accounts.push(account);
112+
return account;
113+
});
114+
});
85115
}
86116

87117
class MockSolAccountProvider extends SolAccountProvider {
@@ -115,6 +145,7 @@ function setup({
115145
handleRequest: jest.Mock;
116146
keyring: {
117147
createAccount: jest.Mock;
148+
createAccounts: jest.Mock;
118149
};
119150
trace: jest.Mock;
120151
};
@@ -192,6 +223,7 @@ function setup({
192223
handleRequest: mockHandleRequest,
193224
keyring: {
194225
createAccount: keyring.createAccount as jest.Mock,
226+
createAccounts: keyring.createAccounts as jest.Mock,
195227
},
196228
trace: mockTrace,
197229
},
@@ -252,7 +284,7 @@ describe('SolAccountProvider', () => {
252284

253285
it('creates accounts', async () => {
254286
const accounts = [MOCK_SOL_ACCOUNT_1];
255-
const { provider, keyring } = setup({
287+
const { provider, mocks } = setup({
256288
accounts,
257289
});
258290

@@ -263,7 +295,16 @@ describe('SolAccountProvider', () => {
263295
groupIndex: newGroupIndex,
264296
});
265297
expect(newAccounts).toHaveLength(1);
266-
expect(keyring.createAccount).toHaveBeenCalled();
298+
// Batch endpoint must be called, NOT the singular one.
299+
expect(mocks.keyring.createAccounts).toHaveBeenCalledWith(
300+
SolAccountProvider.SOLANA_SNAP_ID,
301+
{
302+
type: AccountCreationType.Bip44DeriveIndex,
303+
entropySource: MOCK_HD_KEYRING_1.metadata.id,
304+
groupIndex: newGroupIndex,
305+
},
306+
);
307+
expect(mocks.keyring.createAccount).not.toHaveBeenCalled();
267308
});
268309

269310
it('does not re-create accounts (idempotent)', async () => {
@@ -283,7 +324,7 @@ describe('SolAccountProvider', () => {
283324

284325
it('creates multiple accounts using Bip44DeriveIndexRange', async () => {
285326
const accounts = [MOCK_SOL_ACCOUNT_1];
286-
const { provider, keyring } = setup({
327+
const { provider, mocks } = setup({
287328
accounts,
288329
});
289330

@@ -297,7 +338,9 @@ describe('SolAccountProvider', () => {
297338
});
298339

299340
expect(newAccounts).toHaveLength(3);
300-
expect(keyring.createAccount).toHaveBeenCalledTimes(3);
341+
// Single batch call, NOT three individual calls.
342+
expect(mocks.keyring.createAccounts).toHaveBeenCalledTimes(1);
343+
expect(mocks.keyring.createAccount).not.toHaveBeenCalled();
301344

302345
// Verify each account has the correct group index
303346
expect(
@@ -315,7 +358,7 @@ describe('SolAccountProvider', () => {
315358
});
316359

317360
it('creates accounts with range starting from 0', async () => {
318-
const { provider, keyring } = setup({
361+
const { provider, mocks } = setup({
319362
accounts: [],
320363
});
321364

@@ -329,11 +372,12 @@ describe('SolAccountProvider', () => {
329372
});
330373

331374
expect(newAccounts).toHaveLength(3);
332-
expect(keyring.createAccount).toHaveBeenCalledTimes(3);
375+
expect(mocks.keyring.createAccounts).toHaveBeenCalledTimes(1);
376+
expect(mocks.keyring.createAccount).not.toHaveBeenCalled();
333377
});
334378

335379
it('creates a single account when range from equals to', async () => {
336-
const { provider, keyring } = setup({
380+
const { provider, mocks } = setup({
337381
accounts: [],
338382
});
339383

@@ -347,7 +391,8 @@ describe('SolAccountProvider', () => {
347391
});
348392

349393
expect(newAccounts).toHaveLength(1);
350-
expect(keyring.createAccount).toHaveBeenCalledTimes(1);
394+
expect(mocks.keyring.createAccounts).toHaveBeenCalledTimes(1);
395+
expect(mocks.keyring.createAccount).not.toHaveBeenCalled();
351396
expect(
352397
isBip44Account(newAccounts[0]) &&
353398
newAccounts[0].options.entropy.groupIndex,
@@ -359,10 +404,10 @@ describe('SolAccountProvider', () => {
359404
accounts: [],
360405
});
361406

362-
mocks.keyring.createAccount.mockImplementation(() => {
407+
mocks.keyring.createAccounts.mockImplementation(() => {
363408
return new Promise((resolve) => {
364409
setTimeout(() => {
365-
resolve(MOCK_SOL_ACCOUNT_1);
410+
resolve([MOCK_SOL_ACCOUNT_1]);
366411
}, 4000);
367412
});
368413
});

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

Lines changed: 39 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -113,52 +113,50 @@ export class SolAccountProvider extends SnapAccountProvider {
113113
`${AccountCreationType.Bip44DeriveIndexRange}`,
114114
]);
115115

116-
const { entropySource } = options;
117-
118-
if (options.type === AccountCreationType.Bip44DeriveIndexRange) {
119-
const { range } = options;
120-
return this.withSnap(async ({ keyring }) => {
121-
const accounts: Bip44Account<KeyringAccount>[] = [];
122-
123-
// TODO: Use `SnapKeyring.createAccounts` when that functionality is implemented on the Snap
124-
// itself, instead of creating accounts one by one.
125-
for (
126-
let groupIndex = range.from;
127-
groupIndex <= range.to;
128-
groupIndex++
129-
) {
130-
const account = await this.withMaxConcurrency(async () => {
131-
const derivationPath = `m/44'/501'/${groupIndex}'/0'`;
132-
return this.#createAccount({
133-
keyring,
134-
entropySource,
135-
groupIndex,
136-
derivationPath,
137-
});
138-
});
139-
140-
this.accounts.add(account.id);
141-
accounts.push(account);
116+
return this.withSnap(async ({ keyring }) => {
117+
return this.withMaxConcurrency(async () => {
118+
let groupIndexOffset = 0;
119+
let snapAccounts: KeyringAccount[];
120+
121+
const { entropySource } = options;
122+
123+
if (options.type === `${AccountCreationType.Bip44DeriveIndexRange}`) {
124+
snapAccounts = await withTimeout(
125+
keyring.createAccounts(options),
126+
this.config.createAccounts.timeoutMs,
127+
);
128+
129+
// Group indices are sequential, so we just need the starting index.
130+
groupIndexOffset = options.range.from;
131+
} else {
132+
const [snapAccount] = await withTimeout(
133+
keyring.createAccounts(options),
134+
this.config.createAccounts.timeoutMs,
135+
);
136+
snapAccounts = [snapAccount];
137+
138+
// For single account, there will only be 1 account, so we can use the
139+
// provided group index directly.
140+
groupIndexOffset = options.groupIndex;
142141
}
143142

144-
return accounts;
145-
});
146-
}
143+
// NOTE: We still need to convert accounts to proper BIP-44 accounts for now.
144+
return snapAccounts.map((snapAccount, index) => {
145+
const groupIndex = groupIndexOffset + index;
146+
const derivationPath = `m/44'/501'/${groupIndex}'/0'`;
147147

148-
const { groupIndex } = options;
148+
// Ensure entropy is present before type assertion validation
149+
snapAccount.options.entropy = {
150+
type: KeyringAccountEntropyTypeOption.Mnemonic,
151+
id: entropySource,
152+
groupIndex,
153+
derivationPath,
154+
};
149155

150-
return this.withSnap(async ({ keyring }) => {
151-
return this.withMaxConcurrency(async () => {
152-
const derivationPath = `m/44'/501'/${groupIndex}'/0'`;
153-
const account = await this.#createAccount({
154-
keyring,
155-
entropySource,
156-
groupIndex,
157-
derivationPath,
158-
});
156+
assertIsBip44Account(snapAccount);
159157

160-
this.accounts.add(account.id);
161-
return [account];
158+
return snapAccount;
159+
});
162160
});
163161
});
164162
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ class MockTronKeyring {
6969
return account;
7070
});
7171

72+
createAccounts: SnapKeyring['createAccounts'] = jest.fn();
73+
7274
// Add discoverAccounts method to match the provider's usage
7375
discoverAccounts = jest.fn().mockResolvedValue([]);
7476
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
KeyringAccount,
55
KeyringCapabilities,
66
} from '@metamask/keyring-api';
7+
import { GroupIndexRange } from 'src/utils';
78

89
import { AccountProviderWrapper, EvmAccountProvider } from '../providers';
910

@@ -140,3 +141,19 @@ export function mockCreateAccountsOnce(
140141
return created;
141142
});
142143
}
144+
145+
/**
146+
* Helper to convert a group index range to an array of group indices, inclusive of the
147+
* start and end indices.
148+
*
149+
* @param range - The range.
150+
* @param range.from - The starting index of the range (inclusive).
151+
* @param range.to - The ending index of the range (inclusive).
152+
* @returns An array of group indices from `from` to `to`, inclusive.
153+
*/
154+
export function toGroupIndexRangeArray({
155+
from = 0,
156+
to,
157+
}: GroupIndexRange): number[] {
158+
return Array.from({ length: to - from + 1 }, (_, i) => from + i);
159+
}

0 commit comments

Comments
 (0)