Skip to content
Merged
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
7 changes: 7 additions & 0 deletions packages/keyring-api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `AccountCreationType.AddressImport` (`address:import`) create account option for importing watch-only accounts by address ([#643](https://github.com/MetaMask/accounts/pull/643))
- Includes an optional `accountType` field, matching `private-key:import`, since the account type cannot always be detected from the address alone.
- Includes an optional `scopes` field, since the scope cannot always be detected from the address alone (e.g. an EVM address is valid on every EVM chain). When omitted, the keyring decides the scopes.
- Add `address` capability to `KeyringCapabilities`, declaring whether a keyring supports importing watch-only accounts by address ([#643](https://github.com/MetaMask/accounts/pull/643))

## [24.1.0]

### Added
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Infer } from '@metamask/superstruct';
import {
array,
exactOptional,
literal,
nonempty,
object,
string,
} from '@metamask/superstruct';

import { KeyringAccountTypeStruct } from '../../../api/account';
import { CaipChainIdStruct } from '../../../api/caip';

/**
* Struct for {@link CreateAccountAddressImportOptions}.
*/
export const CreateAccountAddressImportOptionsStruct = object({
/**
* The type of the options.
*/
type: literal('address:import'),
/**
* The address to be imported.
*/
address: string(),
/**
* The account type of the imported account.
*
* This is needed because the account type cannot always be detected from
* the address alone (e.g., an EVM address may be an EOA or an ERC-4337
* account). When omitted, the keyring decides the account type, typically
* defaulting to the chain's standard account type.
*/
accountType: exactOptional(KeyringAccountTypeStruct),
Comment thread
gantunesr marked this conversation as resolved.
/**
* The scopes (CAIP-2 chain IDs) of the imported account.
*
* This is needed because the scope cannot always be detected from the
* address alone (e.g., an EVM address is valid on every EVM chain, and
* Solana or TRON addresses do not encode a network). When omitted, the
* keyring decides the scopes: it infers them from the address where
* possible, and falls back to its own default policy otherwise.
*/
scopes: exactOptional(nonempty(array(CaipChainIdStruct))),
});

/**
* Options for importing a watch-only account from an address.
*/
export type CreateAccountAddressImportOptions = Infer<
typeof CreateAccountAddressImportOptionsStruct
>;
112 changes: 112 additions & 0 deletions packages/keyring-api/src/v2/api/create-account/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,44 @@ describe('CreateAccountOptionsStruct', () => {
assert(validPrivateKey, CreateAccountOptionsStruct),
).not.toThrow();
});

it('validates AddressImport type correctly', () => {
const validAddressImport = {
type: AccountCreationType.AddressImport,
address: '0x0123456789012345678901234567890123456789',
};

expect(is(validAddressImport, CreateAccountOptionsStruct)).toBe(true);
expect(() =>
assert(validAddressImport, CreateAccountOptionsStruct),
).not.toThrow();
});

it('validates AddressImport type with accountType correctly', () => {
const validAddressImport = {
type: AccountCreationType.AddressImport,
address: '0x0123456789012345678901234567890123456789',
accountType: 'eip155:erc4337',
};

expect(is(validAddressImport, CreateAccountOptionsStruct)).toBe(true);
expect(() =>
assert(validAddressImport, CreateAccountOptionsStruct),
).not.toThrow();
});

it('validates AddressImport type with scopes correctly', () => {
const validAddressImport = {
type: AccountCreationType.AddressImport,
address: '0x0123456789012345678901234567890123456789',
scopes: ['eip155:1', 'eip155:137'],
};

expect(is(validAddressImport, CreateAccountOptionsStruct)).toBe(true);
expect(() =>
assert(validAddressImport, CreateAccountOptionsStruct),
).not.toThrow();
});
});

describe('invalid account creation types', () => {
Expand Down Expand Up @@ -168,6 +206,56 @@ describe('CreateAccountOptionsStruct', () => {
).toThrow(/privateKey/u);
});

it('rejects AddressImport type with missing address', () => {
const missingAddress = {
type: AccountCreationType.AddressImport,
};

expect(is(missingAddress, CreateAccountOptionsStruct)).toBe(false);
expect(() => assert(missingAddress, CreateAccountOptionsStruct)).toThrow(
/address/u,
);
});

it('rejects AddressImport type with invalid accountType', () => {
const invalidAccountType = {
type: AccountCreationType.AddressImport,
address: '0x0123456789012345678901234567890123456789',
accountType: 'unsupported:account-type',
};

expect(is(invalidAccountType, CreateAccountOptionsStruct)).toBe(false);
expect(() =>
assert(invalidAccountType, CreateAccountOptionsStruct),
).toThrow(/accountType/u);
});

it('rejects AddressImport type with empty scopes', () => {
const emptyScopes = {
type: AccountCreationType.AddressImport,
address: '0x0123456789012345678901234567890123456789',
scopes: [],
};

expect(is(emptyScopes, CreateAccountOptionsStruct)).toBe(false);
expect(() => assert(emptyScopes, CreateAccountOptionsStruct)).toThrow(
/scopes/u,
);
});

it('rejects AddressImport type with invalid scopes', () => {
const invalidScopes = {
type: AccountCreationType.AddressImport,
address: '0x0123456789012345678901234567890123456789',
scopes: ['not-a-scope'],
};

expect(is(invalidScopes, CreateAccountOptionsStruct)).toBe(false);
expect(() => assert(invalidScopes, CreateAccountOptionsStruct)).toThrow(
/scopes/u,
);
});

it('rejects wrong fields for type (Bip44DerivePath type with groupIndex instead of derivationPath)', () => {
const wrongFields = {
type: AccountCreationType.Bip44DerivePath,
Expand Down Expand Up @@ -206,6 +294,10 @@ describe('CreateAccountOptionsStruct', () => {
privateKey: '0xabc',
encoding: 'hexadecimal',
},
{
type: AccountCreationType.AddressImport,
address: '0x0123456789012345678901234567890123456789',
},
];

// All should validate successfully
Expand Down Expand Up @@ -259,6 +351,7 @@ describe('assertCreateAccountOptionIsSupported', () => {
AccountCreationType.Bip44DeriveIndexRange,
AccountCreationType.Bip44Discover,
AccountCreationType.PrivateKeyImport,
AccountCreationType.AddressImport,
AccountCreationType.Custom,
];

Expand Down Expand Up @@ -304,6 +397,10 @@ describe('assertCreateAccountOptionIsSupported', () => {
privateKey: '0x1234567890abcdef',
encoding: 'hexadecimal',
},
[AccountCreationType.AddressImport]: {
type: AccountCreationType.AddressImport,
address: '0x0123456789012345678901234567890123456789',
},
[AccountCreationType.Custom]: {
type: AccountCreationType.Custom,
// FIXME: We cannot use custom fields currently with `CreateAccountOptions` type.
Expand Down Expand Up @@ -368,6 +465,21 @@ describe('assertCreateAccountOptionIsSupported', () => {
).toThrow('Unsupported create account option type: private-key:import');
});

it('throws error for AddressImport when only BIP-44 types are supported', () => {
const options = {
type: AccountCreationType.AddressImport,
address: '0x0123456789012345678901234567890123456789',
} as CreateAccountOptions;
const supportedTypes = [
AccountCreationType.Bip44DerivePath,
AccountCreationType.Bip44DeriveIndex,
];

expect(() =>
assertCreateAccountOptionIsSupported(options, supportedTypes),
).toThrow('Unsupported create account option type: address:import');
});

it('throws error for Bip44Discover when not in supportedTypes', () => {
const options = {
type: AccountCreationType.Bip44Discover,
Expand Down
9 changes: 9 additions & 0 deletions packages/keyring-api/src/v2/api/create-account/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { selectiveUnion } from '@metamask/keyring-utils';
import type { Infer } from '@metamask/superstruct';

import { CreateAccountAddressImportOptionsStruct } from './address-import';
import {
CreateAccountBip44DiscoverOptionsStruct,
CreateAccountBip44DeriveIndexOptionsStruct,
Expand All @@ -10,6 +11,7 @@ import {
import { CreateAccountCustomOptionsStruct } from './custom';
import { CreateAccountPrivateKeyOptionsStruct } from './private-key';

export * from './address-import';
export * from './bip44';
export * from './custom';
export * from './private-key';
Expand Down Expand Up @@ -55,6 +57,11 @@ export enum AccountCreationType {
*/
PrivateKeyImport = 'private-key:import',

/**
* Represents a watch-only account imported from an address.
*/
AddressImport = 'address:import',

/**
* Represents an account created using a custom, keyring-specific method.
*
Expand All @@ -80,6 +87,8 @@ export const CreateAccountOptionsStruct = selectiveUnion((value: any) => {
return CreateAccountBip44DiscoverOptionsStruct;
case AccountCreationType.PrivateKeyImport:
return CreateAccountPrivateKeyOptionsStruct;
case AccountCreationType.AddressImport:
return CreateAccountAddressImportOptionsStruct;
case AccountCreationType.Custom:
return CreateAccountCustomOptionsStruct;
default:
Expand Down
12 changes: 12 additions & 0 deletions packages/keyring-api/src/v2/api/keyring-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ export const KeyringCapabilitiesStruct = object({
discover: exactOptional(boolean()),
}),
),
/**
* Address capabilities supported by this keyring.
*/
address: exactOptional(
object({
/**
* Whether the keyring supports importing watch-only accounts by
* address.
*/
import: boolean(),
}),
),
/**
* Private key capabilities supported by this keyring.
*/
Expand Down
58 changes: 58 additions & 0 deletions packages/keyring-api/src/v2/api/keyring.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expectAssignable, expectNotAssignable } from 'tsd';

import { AccountCreationType } from './create-account';
import type {
CreateAccountAddressImportOptions,
CreateAccountBip44DiscoverOptions,
CreateAccountBip44DeriveIndexOptions,
CreateAccountBip44DerivePathOptions,
Expand Down Expand Up @@ -35,6 +36,7 @@ expectAssignable<AccountCreationType>(AccountCreationType.Bip44DerivePath);
expectAssignable<AccountCreationType>(AccountCreationType.Bip44DeriveIndex);
expectAssignable<AccountCreationType>(AccountCreationType.Bip44Discover);
expectAssignable<AccountCreationType>(AccountCreationType.PrivateKeyImport);
expectAssignable<AccountCreationType>(AccountCreationType.AddressImport);
expectAssignable<AccountCreationType>(AccountCreationType.Custom);

// Test AccountExportType enum
Expand Down Expand Up @@ -93,6 +95,21 @@ expectAssignable<KeyringCapabilities>({
},
});

expectAssignable<KeyringCapabilities>({
scopes: ['eip155:1'],
address: {
import: true,
},
});

expectNotAssignable<KeyringCapabilities>({
scopes: ['eip155:1'],
address: {
import: true,
export: true,
},
});

expectNotAssignable<KeyringCapabilities>({
scopes: ['eip155:1'],
custom: {
Expand Down Expand Up @@ -148,6 +165,42 @@ expectAssignable<CreateAccountPrivateKeyOptions>({
encoding: 'base32',
});

// Test CreateAccountAddressImportOptions
expectAssignable<CreateAccountAddressImportOptions>({
type: AccountCreationType.AddressImport,
address: '0x0123456789012345678901234567890123456789',
});

expectAssignable<CreateAccountAddressImportOptions>({
type: AccountCreationType.AddressImport,
address: '0x0123456789012345678901234567890123456789',
accountType: 'eip155:erc4337',
});

expectAssignable<CreateAccountAddressImportOptions>({
type: AccountCreationType.AddressImport,
address: '0x0123456789012345678901234567890123456789',
scopes: ['eip155:1', 'eip155:137'],
});

expectNotAssignable<CreateAccountAddressImportOptions>({
type: AccountCreationType.AddressImport,
address: '0x0123456789012345678901234567890123456789',
// Empty scopes are not allowed
scopes: [],
});

expectNotAssignable<CreateAccountAddressImportOptions>({
type: AccountCreationType.AddressImport,
address: '0x0123456789012345678901234567890123456789',
scopes: 'eip155:1',
});

expectNotAssignable<CreateAccountAddressImportOptions>({
type: AccountCreationType.AddressImport,
// missing address
});

// Test CreateAccountCustomOptions
expectAssignable<CreateAccountCustomOptions>({
type: AccountCreationType.Custom,
Expand All @@ -172,6 +225,11 @@ expectAssignable<CreateAccountOptions>({
encoding: 'hexadecimal',
});

expectAssignable<CreateAccountOptions>({
type: AccountCreationType.AddressImport,
address: '0x0123456789012345678901234567890123456789',
});

expectAssignable<CreateAccountOptions>({
type: AccountCreationType.Custom,
});
Expand Down
Loading