Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
254 changes: 254 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,258 @@ 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);
}
});

it('throws an error when creating generic accounts if not allowed', async () => {
mockCallbacks.addAccount.mockClear();
mockCallbacks.saveState.mockClear();

// Create a keyring with isAnyAccountTypeAllowed = false
const restrictedKeyring = new SnapKeyring({
messenger: mockSnapKeyringMessenger,
callbacks: mockCallbacks,
isAnyAccountTypeAllowed: false,
});

const genericAccount = {
...newAccount1,
type: AnyAccountType.Account,
};

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

const options: CreateAccountOptions = {
type: AccountCreationType.Bip44DeriveIndex,
entropySource,
groupIndex: 0,
};

await expect(
restrictedKeyring.createAccounts(snapId, options),
).rejects.toThrow(`Cannot create generic account '${genericAccount.id}'`);

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

describe('resolveAccountAddress', () => {
const scope = toCaipChainId(
KnownCaipNamespace.Eip155,
Expand Down
Loading
Loading