Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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 packages/keyring-api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add `Keyring.createAccounts` optional method ([#448](https://github.com/MetaMask/accounts/pull/448))
- This method is part of the keyring v2 specification and set as optional for backwards compatibility.
- This method can be used to create one or more accounts using the new keyring v2 account creation typed options.
- Add support for account derivations using range of indices in `KeyringV2` ([#451](https://github.com/MetaMask/accounts/pull/451))
- Add `bip44:derive-index-range` capability to `KeyringCapabilities`.
- Add `AccountCreationType.Bip44DeriveIndexRange` and `CreateAccountBip44DeriveIndexRangeOptions`.
Expand Down
14 changes: 14 additions & 0 deletions packages/keyring-api/src/api/keyring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { Paginated, Pagination } from './pagination';
import type { KeyringRequest } from './request';
import type { KeyringResponse } from './response';
import type { Transaction } from './transaction';
import type { CreateAccountOptions } from './v2';

/**
* Keyring interface.
Expand Down Expand Up @@ -56,6 +57,19 @@ export type Keyring = {
options?: Record<string, Json> & MetaMaskOptions,
): Promise<KeyringAccount>;

/**
* Creates one or more new accounts according to the provided options.
*
* Deterministic account creation MUST be idempotent, meaning that for
* deterministic algorithms, like BIP-44, calling this method with the same
* options should always return the same accounts, even if the accounts
* already exist in the keyring.
*
* @param options - Options describing how to create the account(s).
* @returns A promise that resolves to an array of the created account objects.
*/
createAccounts?(options: CreateAccountOptions): Promise<KeyringAccount[]>;

/**
* Lists the assets of an account (fungibles and non-fungibles) represented
* by their respective CAIP-19:
Expand Down
19 changes: 19 additions & 0 deletions packages/keyring-api/src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
PaginationStruct,
CaipAccountIdStruct,
DiscoveredAccountStruct,
CreateAccountOptionsStruct,
} from './api';

/**
Expand All @@ -36,6 +37,7 @@ import {
export enum KeyringRpcMethod {
// Account management
CreateAccount = 'keyring_createAccount',
CreateAccounts = 'keyring_createAccounts',
DeleteAccount = 'keyring_deleteAccount',
DiscoverAccounts = 'keyring_discoverAccounts',
ExportAccount = 'keyring_exportAccount',
Expand Down Expand Up @@ -126,6 +128,23 @@ export const CreateAccountResponseStruct = KeyringAccountStruct;

export type CreateAccountResponse = Infer<typeof CreateAccountResponseStruct>;

// ----------------------------------------------------------------------------
// Create accounts

export const CreateAccountsRequestStruct = object({
...CommonHeader,
method: literal('keyring_createAccounts'),
params: object({
options: CreateAccountOptionsStruct,
}),
});

export type CreateAccountsRequest = Infer<typeof CreateAccountsRequestStruct>;

export const CreateAccountsResponseStruct = array(KeyringAccountStruct);

export type CreateAccountsResponse = Infer<typeof CreateAccountsResponseStruct>;

// ----------------------------------------------------------------------------
// Set selected accounts

Expand Down
5 changes: 5 additions & 0 deletions packages/keyring-snap-bridge/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `SnapKeyring.createAccounts` method ([#448](https://github.com/MetaMask/accounts/pull/448))
- This method can be used to create one or more accounts using the new keyring v2 account creation typed options.

### Changed

- Bump `@metamask/snaps-controllers` from `^14.0.1` to `^17.2.0` ([#422](https://github.com/MetaMask/accounts/pull/422))
Expand Down
220 changes: 220 additions & 0 deletions packages/keyring-snap-bridge/src/SnapKeyring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
AccountTransactionsUpdatedEventPayload,
AccountAssetListUpdatedEventPayload,
MetaMaskOptions,
CreateAccountOptions,
} from '@metamask/keyring-api';
import {
EthScope,
Expand All @@ -28,6 +29,7 @@ import {
TrxScope,
TrxMethod,
TrxAccountType,
AccountCreationType,
} from '@metamask/keyring-api';
import { SnapManageAccountsMethod } from '@metamask/keyring-snap-sdk';
import type { JsonRpcRequest } from '@metamask/keyring-utils';
Expand Down Expand Up @@ -2520,6 +2522,224 @@ describe('SnapKeyring', () => {
});
});

describe('createAccounts', () => {
const newAccount1 = {
...newEthEoaAccount,
id: 'aa11bb22-cc33-4d44-8e55-ff6677889900',
address: '0xaabbccddee00112233445566778899aabbccddee',
};
const newAccount2 = {
...newEthEoaAccount,
id: 'bb11bb22-cc33-4d44-9e55-ff6677889900',
address: '0xbbccddee00112233445566778899aabbccddeeff',
};
const newAccount3 = {
...newEthEoaAccount,
id: 'cc11bb22-cc33-4d44-ae55-ff6677889900',
address: '0xccddee00112233445566778899aabbccddee0011',
};

const entropySource = '01JQCAKR17JARQXZ0NDP760N1K';

const snapMetadata = {
manifest: {
proposedName: 'snap-name',
},
id: snapId,
enabled: true,
};

it('creates multiple accounts', async () => {
mockCallbacks.addAccount.mockClear();
mockCallbacks.saveState.mockClear();

mockMessenger.get.mockReturnValue(snapMetadata);

const accountsToCreate = [newAccount1, newAccount2, newAccount3];

mockMessengerHandleRequest({
[KeyringRpcMethod.CreateAccounts]: async () => {
// Unlike createAccount, createAccounts does NOT emit AccountCreated events
// for each account. It returns all accounts directly.
return accountsToCreate;
},
});

const options: CreateAccountOptions = {
type: AccountCreationType.Bip44DeriveIndexRange,
entropySource,
range: {
from: 0,
to: 2,
},
};
const result = await keyring.createAccounts(snapId, options);

expect(mockMessenger.handleRequest).toHaveBeenLastCalledWith(
mockKeyringRpcRequest(KeyringRpcMethod.CreateAccounts, options),
);

// Verify all accounts were returned
expect(result).toStrictEqual(accountsToCreate);

// Verify all accounts were added to the internal state
for (const account of accountsToCreate) {
expect(keyring.getAccountByAddress(account.address)).toMatchObject({
...account,
metadata: expect.objectContaining({
snap: expect.objectContaining({
id: snapId,
}),
}),
});
}

// Verify state was saved once after adding all accounts
expect(mockCallbacks.saveState).toHaveBeenCalled();

// IMPORTANT: Unlike createAccount, createAccounts does NOT call addAccount callback
// because accounts are created in batch
expect(mockCallbacks.addAccount).not.toHaveBeenCalled();
});

it('creates a single account through createAccounts', async () => {
mockCallbacks.addAccount.mockClear();
mockCallbacks.saveState.mockClear();

mockMessenger.get.mockReturnValue(snapMetadata);

const accountToCreate = [newAccount1];

mockMessengerHandleRequest({
[KeyringRpcMethod.CreateAccounts]: async () => accountToCreate,
});

const options: CreateAccountOptions = {
type: AccountCreationType.Bip44DeriveIndex,
groupIndex: 0,
entropySource,
};
const result = await keyring.createAccounts(snapId, options);

expect(mockMessenger.handleRequest).toHaveBeenLastCalledWith(
mockKeyringRpcRequest(KeyringRpcMethod.CreateAccounts, options),
);

expect(result).toStrictEqual(accountToCreate);
expect(result).toHaveLength(1);

// Verify the account was added to the internal state
expect(keyring.getAccountByAddress(newAccount1.address)).toMatchObject({
...newAccount1,
metadata: expect.objectContaining({
snap: expect.objectContaining({
id: snapId,
}),
}),
});

expect(mockCallbacks.saveState).toHaveBeenCalled();
expect(mockCallbacks.addAccount).not.toHaveBeenCalled();
});

it('creates accounts with custom options', async () => {
mockCallbacks.addAccount.mockClear();
mockCallbacks.saveState.mockClear();

const accountsToCreate = [newAccount1, newAccount2];
const options: CreateAccountOptions = {
type: AccountCreationType.Custom,
};

mockMessengerHandleRequest({
[KeyringRpcMethod.CreateAccounts]: async () => accountsToCreate,
});

const result = await keyring.createAccounts(snapId, options);

expect(mockMessenger.handleRequest).toHaveBeenLastCalledWith(
mockKeyringRpcRequest(KeyringRpcMethod.CreateAccounts, options),
);

expect(result).toStrictEqual(accountsToCreate);
expect(mockCallbacks.saveState).toHaveBeenCalled();
expect(mockCallbacks.addAccount).not.toHaveBeenCalled();
});

it('handles empty response from Snap', async () => {
mockCallbacks.addAccount.mockClear();
mockCallbacks.saveState.mockClear();

mockMessengerHandleRequest({
[KeyringRpcMethod.CreateAccounts]: async () => [],
});

const options: CreateAccountOptions = {
type: AccountCreationType.Bip44DeriveIndex,
entropySource,
groupIndex: 0,
};
const result = await keyring.createAccounts(snapId, options);

expect(result).toStrictEqual([]);
expect(mockCallbacks.saveState).toHaveBeenCalled();
expect(mockCallbacks.addAccount).not.toHaveBeenCalled();
});

it('handles errors from Snap', async () => {
mockCallbacks.addAccount.mockClear();
mockCallbacks.saveState.mockClear();

const errorMessage = 'Failed to create accounts';

mockMessengerHandleRequest({
[KeyringRpcMethod.CreateAccounts]: async () => {
throw new Error(errorMessage);
},
});

const options: CreateAccountOptions = {
type: AccountCreationType.Bip44DeriveIndex,
entropySource,
groupIndex: 0,
};
await expect(keyring.createAccounts(snapId, options)).rejects.toThrow(
errorMessage,
);

// State should not be saved if account creation fails
expect(mockCallbacks.saveState).not.toHaveBeenCalled();
expect(mockCallbacks.addAccount).not.toHaveBeenCalled();
});

it('adds all accounts to the internal map with correct snapId', async () => {
mockCallbacks.addAccount.mockClear();
mockCallbacks.saveState.mockClear();

mockMessenger.get.mockReturnValue(snapMetadata);

const accountsToCreate = [newAccount1, newAccount2];

mockMessengerHandleRequest({
[KeyringRpcMethod.CreateAccounts]: async () => accountsToCreate,
});

const options: CreateAccountOptions = {
type: AccountCreationType.Bip44DeriveIndex,
entropySource,
groupIndex: 0,
};
await keyring.createAccounts(snapId, options);

// Verify each account is mapped to the correct snapId
for (const account of accountsToCreate) {
const createdAccount = keyring.getAccountByAddress(account.address);
expect(createdAccount).toBeDefined();
expect(createdAccount?.metadata.snap?.id).toBe(snapId);
}
});
});

describe('resolveAccountAddress', () => {
const scope = toCaipChainId(
KnownCaipNamespace.Eip155,
Expand Down
34 changes: 34 additions & 0 deletions packages/keyring-snap-bridge/src/SnapKeyring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
CaipChainId,
MetaMaskOptions,
KeyringResponse,
CreateAccountOptions,
} from '@metamask/keyring-api';
import {
EthBytesStruct,
Expand Down Expand Up @@ -891,6 +892,39 @@ export class SnapKeyring {
});
}

/**
* Create one or more accounts according to the provided options.
*
* This method supports batch account creation for BIP-44 derivation paths,
* allowing the creation of multiple accounts up to a specified maximum index.
*
* @param snapId - Snap ID to create the accounts for.
* @param options - Account creation options.
* @returns A promise that resolves to an array of the created account objects.
*/
async createAccounts(
snapId: SnapId,
options: CreateAccountOptions,
): Promise<KeyringAccount[]> {
const client = new KeyringInternalSnapClient({
messenger: this.#messenger,
snapId,
});

// Add each returned account to the internal accounts map.
// NOTE: This method DOES NOT rely on the `AccountCreated` event to add
// accounts to the keyring, since those accounts are created in batch.
const accounts = await client.createAccounts(options);
for (const account of accounts) {
this.#accounts.set(account.id, { account, snapId });
}

// Save the state after adding all accounts.
await this.#callbacks.saveState();

return accounts;
}

/**
* Checks if a Snap ID is known from the keyring.
*
Expand Down
5 changes: 5 additions & 0 deletions packages/keyring-snap-client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `KeyringClient.createAccounts` method ([#448](https://github.com/MetaMask/accounts/pull/448))
- This method can be used to create one or more accounts using the new keyring v2 account creation typed options.

## [8.1.1]

### Changed
Expand Down
Loading
Loading