From d156b333d1188c88bcd8551ee10c75bebc111266 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 30 Sep 2026 12:03:49 +0200 Subject: [PATCH 1/4] feat(keyring-api): add address:import account constructor --- packages/keyring-api/CHANGELOG.md | 6 ++ .../v2/api/create-account/address-import.ts | 34 +++++++++ .../src/v2/api/create-account/index.test.ts | 73 +++++++++++++++++++ .../src/v2/api/create-account/index.ts | 9 +++ .../src/v2/api/keyring-capabilities.ts | 12 +++ .../keyring-api/src/v2/api/keyring.test-d.ts | 40 ++++++++++ 6 files changed, 174 insertions(+) create mode 100644 packages/keyring-api/src/v2/api/create-account/address-import.ts diff --git a/packages/keyring-api/CHANGELOG.md b/packages/keyring-api/CHANGELOG.md index d479e3d02..b76a1f36c 100644 --- a/packages/keyring-api/CHANGELOG.md +++ b/packages/keyring-api/CHANGELOG.md @@ -7,6 +7,12 @@ 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 ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) + - Includes an optional `accountType` field, matching `private-key:import`, since the account type cannot always be detected from the address alone. +- Add `address` capability to `KeyringCapabilities`, declaring whether a keyring supports importing watch-only accounts by address ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) + ## [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..417756b83 --- /dev/null +++ b/packages/keyring-api/src/v2/api/create-account/address-import.ts @@ -0,0 +1,34 @@ +import { exactOptional, literal, object, string } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +import { KeyringAccountTypeStruct } from '../../../api/account'; + +/** + * 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), +}); + +/** + * 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..400e608b7 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,31 @@ 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(); + }); }); describe('invalid account creation types', () => { @@ -168,6 +193,30 @@ 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 wrong fields for type (Bip44DerivePath type with groupIndex instead of derivationPath)', () => { const wrongFields = { type: AccountCreationType.Bip44DerivePath, @@ -206,6 +255,10 @@ describe('CreateAccountOptionsStruct', () => { privateKey: '0xabc', encoding: 'hexadecimal', }, + { + type: AccountCreationType.AddressImport, + address: '0x0123456789012345678901234567890123456789', + }, ]; // All should validate successfully @@ -259,6 +312,7 @@ describe('assertCreateAccountOptionIsSupported', () => { AccountCreationType.Bip44DeriveIndexRange, AccountCreationType.Bip44Discover, AccountCreationType.PrivateKeyImport, + AccountCreationType.AddressImport, AccountCreationType.Custom, ]; @@ -304,6 +358,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 +426,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..dfd264102 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, @@ -29,12 +30,14 @@ expectAssignable(KeyringType.Snap); expectAssignable(KeyringType.Ledger); expectAssignable(KeyringType.Lattice); expectAssignable(KeyringType.Trezor); +expectAssignable(KeyringType.WatchOnly); // Test AccountCreationType enum expectAssignable(AccountCreationType.Bip44DerivePath); expectAssignable(AccountCreationType.Bip44DeriveIndex); expectAssignable(AccountCreationType.Bip44Discover); expectAssignable(AccountCreationType.PrivateKeyImport); +expectAssignable(AccountCreationType.AddressImport); expectAssignable(AccountCreationType.Custom); // Test AccountExportType enum @@ -93,6 +96,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 +166,23 @@ expectAssignable({ encoding: 'base32', }); +// Test CreateAccountAddressImportOptions +expectAssignable({ + type: AccountCreationType.AddressImport, + address: '0x0123456789012345678901234567890123456789', +}); + +expectAssignable({ + type: AccountCreationType.AddressImport, + address: '0x0123456789012345678901234567890123456789', + accountType: 'eip155:erc4337', +}); + +expectNotAssignable({ + type: AccountCreationType.AddressImport, + // missing address +}); + // Test CreateAccountCustomOptions expectAssignable({ type: AccountCreationType.Custom, @@ -172,6 +207,11 @@ expectAssignable({ encoding: 'hexadecimal', }); +expectAssignable({ + type: AccountCreationType.AddressImport, + address: '0x0123456789012345678901234567890123456789', +}); + expectAssignable({ type: AccountCreationType.Custom, }); From 0324487101f914d1c2d523ba19c95257428f2b65 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 30 Sep 2026 12:07:37 +0200 Subject: [PATCH 2/4] chore: changelog --- packages/keyring-api/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/keyring-api/CHANGELOG.md b/packages/keyring-api/CHANGELOG.md index b76a1f36c..1af113bce 100644 --- a/packages/keyring-api/CHANGELOG.md +++ b/packages/keyring-api/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `AccountCreationType.AddressImport` (`address:import`) create account option for importing watch-only accounts by address ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) +- 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. -- Add `address` capability to `KeyringCapabilities`, declaring whether a keyring supports importing watch-only accounts by address ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) +- 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] From 29803c66704f9077ab0b327cb1827408aaf71add Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 30 Sep 2026 14:12:25 +0200 Subject: [PATCH 3/4] refactor: remove WatchOnly enum value test --- packages/keyring-api/src/v2/api/keyring.test-d.ts | 1 - 1 file changed, 1 deletion(-) 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 dfd264102..5b74e3f4f 100644 --- a/packages/keyring-api/src/v2/api/keyring.test-d.ts +++ b/packages/keyring-api/src/v2/api/keyring.test-d.ts @@ -30,7 +30,6 @@ expectAssignable(KeyringType.Snap); expectAssignable(KeyringType.Ledger); expectAssignable(KeyringType.Lattice); expectAssignable(KeyringType.Trezor); -expectAssignable(KeyringType.WatchOnly); // Test AccountCreationType enum expectAssignable(AccountCreationType.Bip44DerivePath); From cd3f5a538558b7b90c1c0ce80e24fdc52eedc9ac Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 30 Sep 2026 16:24:16 +0200 Subject: [PATCH 4/4] feat: add optional scopes --- packages/keyring-api/CHANGELOG.md | 1 + .../v2/api/create-account/address-import.ts | 20 +++++++++- .../src/v2/api/create-account/index.test.ts | 39 +++++++++++++++++++ .../keyring-api/src/v2/api/keyring.test-d.ts | 19 +++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/keyring-api/CHANGELOG.md b/packages/keyring-api/CHANGELOG.md index 1af113bce..f1d7b20c5 100644 --- a/packages/keyring-api/CHANGELOG.md +++ b/packages/keyring-api/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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] 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 index 417756b83..cab9e14eb 100644 --- a/packages/keyring-api/src/v2/api/create-account/address-import.ts +++ b/packages/keyring-api/src/v2/api/create-account/address-import.ts @@ -1,7 +1,15 @@ -import { exactOptional, literal, object, string } from '@metamask/superstruct'; 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}. @@ -24,6 +32,16 @@ export const CreateAccountAddressImportOptionsStruct = object({ * 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))), }); /** 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 400e608b7..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 @@ -101,6 +101,19 @@ describe('CreateAccountOptionsStruct', () => { 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', () => { @@ -217,6 +230,32 @@ describe('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, 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 5b74e3f4f..941e21fd9 100644 --- a/packages/keyring-api/src/v2/api/keyring.test-d.ts +++ b/packages/keyring-api/src/v2/api/keyring.test-d.ts @@ -177,6 +177,25 @@ expectAssignable({ 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