diff --git a/packages/keyring-api/CHANGELOG.md b/packages/keyring-api/CHANGELOG.md index d479e3d02..f1d7b20c5 100644 --- a/packages/keyring-api/CHANGELOG.md +++ b/packages/keyring-api/CHANGELOG.md @@ -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 diff --git a/packages/keyring-api/src/v2/api/create-account/address-import.ts b/packages/keyring-api/src/v2/api/create-account/address-import.ts new file mode 100644 index 000000000..cab9e14eb --- /dev/null +++ b/packages/keyring-api/src/v2/api/create-account/address-import.ts @@ -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), + /** + * 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 +>; diff --git a/packages/keyring-api/src/v2/api/create-account/index.test.ts b/packages/keyring-api/src/v2/api/create-account/index.test.ts index 3bfe7a32b..5fefd2ae2 100644 --- a/packages/keyring-api/src/v2/api/create-account/index.test.ts +++ b/packages/keyring-api/src/v2/api/create-account/index.test.ts @@ -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', () => { @@ -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, @@ -206,6 +294,10 @@ describe('CreateAccountOptionsStruct', () => { privateKey: '0xabc', encoding: 'hexadecimal', }, + { + type: AccountCreationType.AddressImport, + address: '0x0123456789012345678901234567890123456789', + }, ]; // All should validate successfully @@ -259,6 +351,7 @@ describe('assertCreateAccountOptionIsSupported', () => { AccountCreationType.Bip44DeriveIndexRange, AccountCreationType.Bip44Discover, AccountCreationType.PrivateKeyImport, + AccountCreationType.AddressImport, AccountCreationType.Custom, ]; @@ -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. @@ -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, diff --git a/packages/keyring-api/src/v2/api/create-account/index.ts b/packages/keyring-api/src/v2/api/create-account/index.ts index 0bfa97586..e07d4c1b4 100644 --- a/packages/keyring-api/src/v2/api/create-account/index.ts +++ b/packages/keyring-api/src/v2/api/create-account/index.ts @@ -1,6 +1,7 @@ import { selectiveUnion } from '@metamask/keyring-utils'; import type { Infer } from '@metamask/superstruct'; +import { CreateAccountAddressImportOptionsStruct } from './address-import'; import { CreateAccountBip44DiscoverOptionsStruct, CreateAccountBip44DeriveIndexOptionsStruct, @@ -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'; @@ -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. * @@ -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: diff --git a/packages/keyring-api/src/v2/api/keyring-capabilities.ts b/packages/keyring-api/src/v2/api/keyring-capabilities.ts index 8609727e6..effaea5c8 100644 --- a/packages/keyring-api/src/v2/api/keyring-capabilities.ts +++ b/packages/keyring-api/src/v2/api/keyring-capabilities.ts @@ -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. */ diff --git a/packages/keyring-api/src/v2/api/keyring.test-d.ts b/packages/keyring-api/src/v2/api/keyring.test-d.ts index c18c4d21d..941e21fd9 100644 --- a/packages/keyring-api/src/v2/api/keyring.test-d.ts +++ b/packages/keyring-api/src/v2/api/keyring.test-d.ts @@ -2,6 +2,7 @@ import { expectAssignable, expectNotAssignable } from 'tsd'; import { AccountCreationType } from './create-account'; import type { + CreateAccountAddressImportOptions, CreateAccountBip44DiscoverOptions, CreateAccountBip44DeriveIndexOptions, CreateAccountBip44DerivePathOptions, @@ -35,6 +36,7 @@ expectAssignable(AccountCreationType.Bip44DerivePath); expectAssignable(AccountCreationType.Bip44DeriveIndex); expectAssignable(AccountCreationType.Bip44Discover); expectAssignable(AccountCreationType.PrivateKeyImport); +expectAssignable(AccountCreationType.AddressImport); expectAssignable(AccountCreationType.Custom); // Test AccountExportType enum @@ -93,6 +95,21 @@ expectAssignable({ }, }); +expectAssignable({ + scopes: ['eip155:1'], + address: { + import: true, + }, +}); + +expectNotAssignable({ + scopes: ['eip155:1'], + address: { + import: true, + export: true, + }, +}); + expectNotAssignable({ scopes: ['eip155:1'], custom: { @@ -148,6 +165,42 @@ expectAssignable({ encoding: 'base32', }); +// Test CreateAccountAddressImportOptions +expectAssignable({ + type: AccountCreationType.AddressImport, + address: '0x0123456789012345678901234567890123456789', +}); + +expectAssignable({ + type: AccountCreationType.AddressImport, + address: '0x0123456789012345678901234567890123456789', + accountType: 'eip155:erc4337', +}); + +expectAssignable({ + type: AccountCreationType.AddressImport, + address: '0x0123456789012345678901234567890123456789', + scopes: ['eip155:1', 'eip155:137'], +}); + +expectNotAssignable({ + type: AccountCreationType.AddressImport, + address: '0x0123456789012345678901234567890123456789', + // Empty scopes are not allowed + scopes: [], +}); + +expectNotAssignable({ + type: AccountCreationType.AddressImport, + address: '0x0123456789012345678901234567890123456789', + scopes: 'eip155:1', +}); + +expectNotAssignable({ + type: AccountCreationType.AddressImport, + // missing address +}); + // Test CreateAccountCustomOptions expectAssignable({ type: AccountCreationType.Custom, @@ -172,6 +225,11 @@ expectAssignable({ encoding: 'hexadecimal', }); +expectAssignable({ + type: AccountCreationType.AddressImport, + address: '0x0123456789012345678901234567890123456789', +}); + expectAssignable({ type: AccountCreationType.Custom, });