diff --git a/.github/workflows/validate-pr-title.yml b/.github/workflows/validate-pr-title.yml index 793651391..9ef3dbabb 100644 --- a/.github/workflows/validate-pr-title.yml +++ b/.github/workflows/validate-pr-title.yml @@ -54,6 +54,7 @@ jobs: keyring-snap-client keyring-snap-sdk keyring-utils + keyring-watch-only account-api account-api/mocks subjectPattern: '^(?![A-Z]).+$' diff --git a/README.md b/README.md index 7cc92fd36..4fcb5d59f 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ This repository contains the following packages [^fn1]: - [`@metamask/keyring-snap-client`](packages/keyring-snap-client) - [`@metamask/keyring-snap-sdk`](packages/keyring-snap-sdk) - [`@metamask/keyring-utils`](packages/keyring-utils) +- [`@metamask/watch-only-keyring`](packages/keyring-watch-only) @@ -61,6 +62,7 @@ linkStyle default opacity:0.5 keyring_snap_client(["@metamask/keyring-snap-client"]); keyring_snap_sdk(["@metamask/keyring-snap-sdk"]); keyring_utils(["@metamask/keyring-utils"]); + watch_only_keyring(["@metamask/watch-only-keyring"]); account_api --> keyring_api; account_api --> keyring_utils; keyring_api --> keyring_utils; @@ -107,6 +109,9 @@ linkStyle default opacity:0.5 keyring_snap_client --> keyring_utils; keyring_snap_sdk --> keyring_utils; keyring_snap_sdk --> keyring_api; + watch_only_keyring --> keyring_api; + watch_only_keyring --> keyring_sdk; + watch_only_keyring --> keyring_utils; ``` diff --git a/packages/keyring-api/CHANGELOG.md b/packages/keyring-api/CHANGELOG.md index f1d7b20c5..5e3000b51 100644 --- a/packages/keyring-api/CHANGELOG.md +++ b/packages/keyring-api/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) +- Add `KeyringType.WatchOnly` keyring type ([#644](https://github.com/MetaMask/accounts/pull/644)) ## [24.1.0] diff --git a/packages/keyring-api/src/v2/api/keyring-type.ts b/packages/keyring-api/src/v2/api/keyring-type.ts index 06ec60620..35d7bc42b 100644 --- a/packages/keyring-api/src/v2/api/keyring-type.ts +++ b/packages/keyring-api/src/v2/api/keyring-type.ts @@ -48,4 +48,10 @@ export enum KeyringType { * Represents keyring for money accounts. */ Money = 'money', + + /** + * Represents a watch-only keyring that holds accounts imported by address, + * without any signing capability. + */ + WatchOnly = 'watch-only', } 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 941e21fd9..ee00a3164 100644 --- a/packages/keyring-api/src/v2/api/keyring.test-d.ts +++ b/packages/keyring-api/src/v2/api/keyring.test-d.ts @@ -30,6 +30,7 @@ expectAssignable(KeyringType.Snap); expectAssignable(KeyringType.Ledger); expectAssignable(KeyringType.Lattice); expectAssignable(KeyringType.Trezor); +expectAssignable(KeyringType.WatchOnly); // Test AccountCreationType enum expectAssignable(AccountCreationType.Bip44DerivePath); diff --git a/packages/keyring-watch-only/CHANGELOG.md b/packages/keyring-watch-only/CHANGELOG.md new file mode 100644 index 000000000..4fea6f622 --- /dev/null +++ b/packages/keyring-watch-only/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release of `@metamask/watch-only-keyring` ([#644](https://github.com/MetaMask/accounts/pull/644)) + - A watch-only v2 `Keyring` that holds accounts imported by address, without any signing capability. + - Supports creating accounts via the `address:import` option, with an optional `accountType` (defaulting to `eip155:eoa`) and optional `scopes` (defaulting to the keyring's scopes, since the scope cannot always be detected from the address alone). + - Includes `WatchOnlyKeyringV1Adapter`, which adapts the keyring to the legacy v1 keyring API for `KeyringController` compatibility, adding address-based account removal. + - Includes synchronous `lookupAccount` and `lookupByAddress` methods for `AccountsController` integration. + +[Unreleased]: https://github.com/MetaMask/accounts/ diff --git a/packages/keyring-watch-only/LICENSE b/packages/keyring-watch-only/LICENSE new file mode 100644 index 000000000..b5ed1b9c5 --- /dev/null +++ b/packages/keyring-watch-only/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2020 MetaMask + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/packages/keyring-watch-only/README.md b/packages/keyring-watch-only/README.md new file mode 100644 index 000000000..0e1a934fd --- /dev/null +++ b/packages/keyring-watch-only/README.md @@ -0,0 +1,26 @@ +# Watch-Only Keyring + +A watch-only keyring that holds accounts imported by address, without any +signing capability. + +Watch-only accounts make it possible to track addresses as first-class +[`KeyringAccount`](https://github.com/MetaMask/accounts/tree/main/packages/keyring-api) +objects without holding any secret material. The keyring is a v2-only +[`Keyring`](https://github.com/MetaMask/accounts/tree/main/packages/keyring-api) +implementation: it cannot sign (`submitRequest` always throws), cannot export +accounts, and its accounts declare no methods. + +EVM addresses are supported for now. Addresses are validated and normalized to +their EIP-55 checksum representation. + +## Installation + +`yarn add @metamask/watch-only-keyring` + +or + +`npm install @metamask/watch-only-keyring` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/accounts#readme). diff --git a/packages/keyring-watch-only/jest.config.js b/packages/keyring-watch-only/jest.config.js new file mode 100644 index 000000000..7eb53e602 --- /dev/null +++ b/packages/keyring-watch-only/jest.config.js @@ -0,0 +1,19 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // The glob patterns Jest uses to detect test files + testMatch: ['**/*.test.[jt]s?(x)'], +}); diff --git a/packages/keyring-watch-only/package.json b/packages/keyring-watch-only/package.json new file mode 100644 index 000000000..1a473c560 --- /dev/null +++ b/packages/keyring-watch-only/package.json @@ -0,0 +1,84 @@ +{ + "name": "@metamask/watch-only-keyring", + "version": "0.1.0", + "description": "A watch-only keyring that holds accounts imported by address, without any signing capability", + "keywords": [ + "ethereum", + "keyring", + "watch-only" + ], + "homepage": "https://github.com/MetaMask/accounts/tree/main/packages/keyring-watch-only#readme", + "bugs": { + "url": "https://github.com/MetaMask/accounts/issues" + }, + "license": "ISC", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/accounts.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:clean": "yarn build --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/watch-only-keyring", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/watch-only-keyring", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "yarn test:source && yarn test:types", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:source": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:types": "../../scripts/tsd-test.sh ./src", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/keyring-api": "^24.1.0", + "@metamask/keyring-sdk": "^3.1.0", + "@metamask/keyring-utils": "^5.0.0", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.11.0", + "async-mutex": "^0.5.0" + }, + "devDependencies": { + "@lavamoat/allow-scripts": "^3.2.1", + "@lavamoat/preinstall-always-fail": "^2.1.0", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.3", + "@types/jest": "^29.5.12", + "deepmerge": "^4.2.2", + "jest": "^29.5.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": ">=22" + }, + "lavamoat": { + "allowScripts": { + "@lavamoat/preinstall-always-fail": false + } + } +} diff --git a/packages/keyring-watch-only/src/index.ts b/packages/keyring-watch-only/src/index.ts new file mode 100644 index 000000000..3bb68136d --- /dev/null +++ b/packages/keyring-watch-only/src/index.ts @@ -0,0 +1,2 @@ +export * from './watch-only-keyring'; +export * from './watch-only-keyring-v1-adapter'; diff --git a/packages/keyring-watch-only/src/watch-only-keyring-v1-adapter.test.ts b/packages/keyring-watch-only/src/watch-only-keyring-v1-adapter.test.ts new file mode 100644 index 000000000..6fc86cfdd --- /dev/null +++ b/packages/keyring-watch-only/src/watch-only-keyring-v1-adapter.test.ts @@ -0,0 +1,188 @@ +import { AccountCreationType } from '@metamask/keyring-api'; +import { KeyringType } from '@metamask/keyring-api/v2'; +import { + EthKeyringV1AccountNotFoundError, + EthKeyringV1MethodNotSupportedError, + KeyringV1Adapter, +} from '@metamask/keyring-sdk/v2'; + +import { WatchOnlyKeyring } from './watch-only-keyring'; +import { + isWatchOnlyKeyringV1Adapter, + WatchOnlyKeyringV1Adapter, +} from './watch-only-keyring-v1-adapter'; + +const TEST_ADDRESS_1 = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'; +const TEST_ADDRESS_1_CHECKSUMMED = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + +type SetupOptions = { + addresses?: string[]; +}; + +type SetupResult = { + adapter: WatchOnlyKeyringV1Adapter; + inner: WatchOnlyKeyring; + mocks: { + deleteAccount: jest.SpyInstance< + ReturnType, + Parameters + >; + lookupByAddress: jest.SpyInstance< + ReturnType, + Parameters + >; + }; +}; + +async function setup({ + addresses = [TEST_ADDRESS_1], +}: SetupOptions = {}): Promise { + const inner = new WatchOnlyKeyring(); + + for (const address of addresses) { + await inner.createAccounts({ + type: AccountCreationType.AddressImport, + address, + }); + } + + const deleteAccount = jest.spyOn(inner, 'deleteAccount'); + const lookupByAddress = jest.spyOn(inner, 'lookupByAddress'); + + return { + adapter: new WatchOnlyKeyringV1Adapter(inner), + inner, + mocks: { deleteAccount, lookupByAddress }, + }; +} + +describe('isWatchOnlyKeyringV1Adapter', () => { + it('returns true for a real WatchOnlyKeyringV1Adapter instance', async () => { + const { adapter } = await setup(); + + expect(isWatchOnlyKeyringV1Adapter(adapter)).toBe(true); + }); + + it('returns false for null', () => { + expect(isWatchOnlyKeyringV1Adapter(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isWatchOnlyKeyringV1Adapter(undefined)).toBe(false); + }); + + it('returns false when type does not match', () => { + expect( + isWatchOnlyKeyringV1Adapter({ type: 'hd', unwrap: () => ({}) }), + ).toBe(false); + }); + + it('returns false when unwrap is missing', () => { + expect(isWatchOnlyKeyringV1Adapter({ type: KeyringType.WatchOnly })).toBe( + false, + ); + }); + + it('returns true for a duck-typed object with matching type and unwrap', () => { + expect( + isWatchOnlyKeyringV1Adapter({ + type: KeyringType.WatchOnly, + unwrap: () => ({}), + }), + ).toBe(true); + }); + + it('returns false for a raw WatchOnlyKeyring (v2) that lacks unwrap', async () => { + const { inner } = await setup(); + + expect(isWatchOnlyKeyringV1Adapter(inner)).toBe(false); + }); +}); + +describe('WatchOnlyKeyringV1Adapter', () => { + it('inherits generic v1 adapter behavior', async () => { + const { adapter, inner } = await setup(); + const state = await inner.serialize(); + + expect(adapter).toBeInstanceOf(KeyringV1Adapter); + expect(adapter.type).toBe(KeyringType.WatchOnly); + expect(adapter.unwrap()).toBe(inner); + expect(await adapter.getAccounts()).toStrictEqual([ + TEST_ADDRESS_1_CHECKSUMMED, + ]); + expect(await adapter.serialize()).toStrictEqual(state); + + await adapter.deserialize(state); + + expect(await adapter.getAccounts()).toStrictEqual([ + TEST_ADDRESS_1_CHECKSUMMED, + ]); + }); + + describe('removeAccount', () => { + it('removes an account by resolving its address and deleting its account ID', async () => { + const { adapter, inner, mocks } = await setup(); + + const account = inner.lookupByAddress(TEST_ADDRESS_1); + + await adapter.removeAccount(TEST_ADDRESS_1); + + expect(mocks.lookupByAddress).toHaveBeenCalledWith(TEST_ADDRESS_1); + expect(mocks.deleteAccount).toHaveBeenCalledWith(account?.id); + expect(await inner.getAccounts()).toStrictEqual([]); + }); + + it('matches the address case-insensitively', async () => { + const { adapter, inner } = await setup(); + + await adapter.removeAccount(TEST_ADDRESS_1_CHECKSUMMED); + + expect(await inner.getAccounts()).toStrictEqual([]); + }); + + it('throws if no account matches the address', async () => { + const { adapter, mocks } = await setup({ addresses: [] }); + + await expect(adapter.removeAccount(TEST_ADDRESS_1)).rejects.toThrow( + 'Account not found', + ); + expect(mocks.deleteAccount).not.toHaveBeenCalled(); + }); + }); + + describe('inherited signing methods', () => { + it('throws when signing a personal message, since watch-only accounts declare no methods', async () => { + const { adapter } = await setup(); + + await expect( + adapter.signPersonalMessage(TEST_ADDRESS_1_CHECKSUMMED, '0xdeadbeef'), + ).rejects.toThrow(EthKeyringV1MethodNotSupportedError); + }); + + it('throws when signing a transaction, since watch-only accounts declare no methods', async () => { + const { adapter } = await setup(); + + await expect( + adapter.signTransaction(TEST_ADDRESS_1_CHECKSUMMED, {} as never), + ).rejects.toThrow(EthKeyringV1MethodNotSupportedError); + }); + + it('throws when the address does not resolve to an account', async () => { + const { adapter } = await setup({ addresses: [] }); + + await expect( + adapter.signPersonalMessage(TEST_ADDRESS_1_CHECKSUMMED, '0xdeadbeef'), + ).rejects.toThrow(EthKeyringV1AccountNotFoundError); + }); + }); + + describe('exportAccount', () => { + it('throws, since the watch-only keyring does not support exporting accounts', async () => { + const { adapter } = await setup(); + + await expect( + adapter.exportAccount(TEST_ADDRESS_1_CHECKSUMMED), + ).rejects.toThrow('Keyring does not support exportAccount'); + }); + }); +}); diff --git a/packages/keyring-watch-only/src/watch-only-keyring-v1-adapter.ts b/packages/keyring-watch-only/src/watch-only-keyring-v1-adapter.ts new file mode 100644 index 000000000..6cb358ff1 --- /dev/null +++ b/packages/keyring-watch-only/src/watch-only-keyring-v1-adapter.ts @@ -0,0 +1,63 @@ +import type { Keyring as KeyringV2 } from '@metamask/keyring-api/v2'; +import { KeyringType } from '@metamask/keyring-api/v2'; +import { EthKeyringV1Adapter } from '@metamask/keyring-sdk/v2'; + +import type { WatchOnlyKeyring } from './watch-only-keyring'; + +/** + * Check if a given keyring instance is a WatchOnlyKeyringV1Adapter. + * + * Uses duck-typing to avoid relying on `instanceof` checks, which can fail in + * certain module resolution scenarios (e.g. when multiple versions of the + * same class exist). + * + * @param keyring - The keyring to check. + * @returns True if the keyring is a WatchOnlyKeyringV1Adapter, false + * otherwise. + */ +export function isWatchOnlyKeyringV1Adapter( + keyring: unknown, +): keyring is WatchOnlyKeyringV1Adapter { + if (keyring === null || keyring === undefined) { + return false; + } + + const adapter = keyring as { type?: KeyringType; unwrap?: () => KeyringV2 }; + + return ( + adapter.type === KeyringType.WatchOnly && + typeof adapter.unwrap === 'function' + ); +} + +/** + * Adapts a `WatchOnlyKeyring` to the legacy v1 keyring API. + * + * The inherited signing methods always throw for watch-only accounts: the + * accounts declare no methods, so the account resolution in + * `EthKeyringV1Adapter` fails with `EthKeyringV1MethodNotSupportedError` + * before reaching this keyring. `exportAccount` also throws, since the + * watch-only keyring does not support exporting accounts. + * + * This subclass only adds address-based account removal, which the base + * adapter does not implement. + */ +export class WatchOnlyKeyringV1Adapter extends EthKeyringV1Adapter { + /** + * Remove the account matching the given address. + * + * Address matching is case-insensitive. + * + * @param address - Address of the account to remove. + * @throws If no account matches the given address. + */ + async removeAccount(address: string): Promise { + const account = this.inner.lookupByAddress(address); + + if (!account) { + throw new Error('Account not found'); + } + + await this.inner.deleteAccount(account.id); + } +} diff --git a/packages/keyring-watch-only/src/watch-only-keyring.test.ts b/packages/keyring-watch-only/src/watch-only-keyring.test.ts new file mode 100644 index 000000000..cabd4e0a2 --- /dev/null +++ b/packages/keyring-watch-only/src/watch-only-keyring.test.ts @@ -0,0 +1,718 @@ +import { + AccountCreationType, + BtcScope, + EthAccountType, + EthScope, +} from '@metamask/keyring-api'; +import type { + CreateAccountOptions, + KeyringAccount, + KeyringRequest, +} from '@metamask/keyring-api'; +import { KeyringType } from '@metamask/keyring-api/v2'; +import type { Keyring } from '@metamask/keyring-api/v2'; +import { getChecksumAddress } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; + +import { isWatchOnlyKeyring, WatchOnlyKeyring } from './watch-only-keyring'; + +const TEST_ADDRESS_1 = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'; +const TEST_ADDRESS_1_CHECKSUMMED = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; +const TEST_ADDRESS_2 = '0xab5801a7d398351b8be11c439e05c5b3259aec9b'; + +function createAddressImportOptions( + address: string, + accountType?: string, + scopes?: string[], +): CreateAccountOptions { + return { + type: AccountCreationType.AddressImport, + address, + ...(accountType ? { accountType } : {}), + ...(scopes ? { scopes } : {}), + } as CreateAccountOptions; +} + +function createMockRequest(accountId: string): KeyringRequest { + return { + id: '00000000-0000-0000-0000-000000000000', + scope: EthScope.Eoa, + account: accountId, + origin: 'http://localhost', + request: { + method: 'personal_sign', + params: [], + }, + }; +} + +describe('WatchOnlyKeyring', () => { + let keyring: WatchOnlyKeyring; + + beforeEach(() => { + keyring = new WatchOnlyKeyring(); + }); + + describe('constructor', () => { + it('exposes the watch-only keyring type', () => { + expect(keyring.type).toBe(KeyringType.WatchOnly); + expect(WatchOnlyKeyring.type).toBe(KeyringType.WatchOnly); + }); + + it('exposes watch-only capabilities', () => { + expect(keyring.capabilities.scopes).toStrictEqual([EthScope.Eoa]); + expect(keyring.capabilities.address).toStrictEqual({ import: true }); + expect(keyring.capabilities.privateKey).toBeUndefined(); + expect(keyring.capabilities.bip44).toBeUndefined(); + }); + + it('does not implement exportAccount', () => { + expect((keyring as Keyring).exportAccount).toBeUndefined(); + }); + }); + + describe('isWatchOnlyKeyring', () => { + it('returns true for a WatchOnlyKeyring', () => { + expect(isWatchOnlyKeyring(keyring)).toBe(true); + }); + + it('returns false for another keyring', () => { + const otherKeyring: Keyring = { + type: 'hd', + capabilities: { scopes: [EthScope.Eoa] }, + serialize: async () => Promise.resolve({}) as Promise, + deserialize: async () => Promise.resolve(), + getAccounts: async () => Promise.resolve([]), + getAccount: () => { + throw new Error('Not implemented'); + }, + createAccounts: async () => Promise.resolve([]), + deleteAccount: async () => Promise.resolve(), + submitRequest: () => { + throw new Error('Not implemented'); + }, + }; + + expect(isWatchOnlyKeyring(otherKeyring)).toBe(false); + }); + }); + + describe('getAccounts', () => { + it('returns an empty array when no accounts exist', async () => { + expect(await keyring.getAccounts()).toStrictEqual([]); + }); + + it('returns all imported accounts', async () => { + await keyring.createAccounts(createAddressImportOptions(TEST_ADDRESS_1)); + await keyring.createAccounts(createAddressImportOptions(TEST_ADDRESS_2)); + + const accounts = await keyring.getAccounts(); + + expect(accounts).toHaveLength(2); + expect(accounts.map((account) => account.address)).toStrictEqual([ + TEST_ADDRESS_1_CHECKSUMMED, + getChecksumAddress(TEST_ADDRESS_2), + ]); + }); + }); + + describe('createAccounts', () => { + it('creates an account from an address', async () => { + const accounts = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + + expect(accounts).toHaveLength(1); + + const account = accounts[0] as KeyringAccount; + expect(account.id).toBeDefined(); + expect(account.address).toBe(TEST_ADDRESS_1_CHECKSUMMED); + expect(account.type).toBe(EthAccountType.Eoa); + expect(account.scopes).toStrictEqual([EthScope.Eoa]); + expect(account.methods).toStrictEqual([]); + expect(account.options).toStrictEqual({}); + }); + + it('normalizes the address to its checksummed representation', async () => { + const accounts = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + + const account = accounts[0] as KeyringAccount; + expect(account.address).toBe(getChecksumAddress(TEST_ADDRESS_1)); + }); + + it('defaults the account type to eoa', async () => { + const accounts = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + + const account = accounts[0] as KeyringAccount; + expect(account.type).toBe(EthAccountType.Eoa); + }); + + it('creates an erc4337 account when requested', async () => { + const accounts = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1, EthAccountType.Erc4337), + ); + + const account = accounts[0] as KeyringAccount; + expect(account.type).toBe(EthAccountType.Erc4337); + }); + + it('uses the provided scopes when supplied', async () => { + const accounts = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1, undefined, [EthScope.Eoa]), + ); + + const account = accounts[0] as KeyringAccount; + expect(account.scopes).toStrictEqual([EthScope.Eoa]); + }); + + it('accepts specific EVM scopes when the keyring supports any EVM scope', async () => { + const accounts = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1, undefined, [ + EthScope.Mainnet, + EthScope.Testnet, + ]), + ); + + const account = accounts[0] as KeyringAccount; + expect(account.scopes).toStrictEqual([ + EthScope.Mainnet, + EthScope.Testnet, + ]); + }); + + it('returns the existing account when re-importing with a scope covered by the existing account', async () => { + const [firstAccount] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + // `eip155:0` (EthScope.Eoa) covers any `eip155:*` scope, so re-importing + // with `eip155:1` (EthScope.Mainnet) should return the existing account. + const [secondAccount] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1, undefined, [ + EthScope.Mainnet, + ]), + ); + + expect(secondAccount).toStrictEqual(firstAccount); + }); + + it('throws when re-importing an address with a different account type', async () => { + await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1, EthAccountType.Eoa), + ); + + await expect( + keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1, EthAccountType.Erc4337), + ), + ).rejects.toThrow( + `Account already exists with type '${EthAccountType.Eoa}', got '${EthAccountType.Erc4337}'.`, + ); + }); + + it('throws when re-importing an address with incompatible scopes', async () => { + await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1, undefined, [ + EthScope.Mainnet, + ]), + ); + // `eip155:1` (EthScope.Mainnet) does not cover `eip155:5` (EthScope.Testnet). + await expect( + keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1, undefined, [ + EthScope.Testnet, + ]), + ), + ).rejects.toThrow( + `Account already exists with scopes [${EthScope.Mainnet}], got [${EthScope.Testnet}].`, + ); + }); + + it('generates deterministic account IDs', async () => { + const [account] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + + const otherKeyring = new WatchOnlyKeyring(); + const [otherAccount] = await otherKeyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1_CHECKSUMMED), + ); + + expect(account?.id).toBe(otherAccount?.id); + }); + + it('is idempotent for an already-imported address', async () => { + const [firstAccount] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + const [secondAccount] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + + expect(secondAccount).toStrictEqual(firstAccount); + + const accounts = await keyring.getAccounts(); + expect(accounts).toHaveLength(1); + }); + + it('is idempotent across address casings', async () => { + const [firstAccount] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + const [secondAccount] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1_CHECKSUMMED), + ); + + expect(secondAccount).toStrictEqual(firstAccount); + + const accounts = await keyring.getAccounts(); + expect(accounts).toHaveLength(1); + }); + + it('handles concurrent account creations', async () => { + await Promise.all([ + keyring.createAccounts(createAddressImportOptions(TEST_ADDRESS_1)), + keyring.createAccounts(createAddressImportOptions(TEST_ADDRESS_2)), + ]); + + expect(await keyring.getAccounts()).toHaveLength(2); + }); + + it('throws for an unsupported account creation type', async () => { + await expect( + keyring.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: 'some-entropy-source', + groupIndex: 0, + }), + ).rejects.toThrow( + 'Unsupported create account option type: bip44:derive-index', + ); + }); + + it('throws for an invalid address', async () => { + await expect( + keyring.createAccounts(createAddressImportOptions('not-an-address')), + ).rejects.toThrow('Invalid EVM address: not-an-address'); + }); + + it('throws for an address that is too short', async () => { + await expect( + keyring.createAccounts(createAddressImportOptions('0x1234')), + ).rejects.toThrow('Invalid EVM address: 0x1234'); + }); + + it('throws for an address with an invalid checksum', async () => { + const invalidChecksumAddress = + '0xD8Da6BF26964aF9D7eEd9e03E53415D37aA96045'; + + await expect( + keyring.createAccounts( + createAddressImportOptions(invalidChecksumAddress), + ), + ).rejects.toThrow(`Invalid EVM address: ${invalidChecksumAddress}`); + }); + + it('throws for a non-EVM account type', async () => { + await expect( + keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1, 'bip122:p2pkh'), + ), + ).rejects.toThrow( + "Unsupported account type for WatchOnlyKeyring: bip122:p2pkh. Only 'eip155:eoa' and 'eip155:erc4337' are supported.", + ); + }); + + it('throws for unsupported scopes', async () => { + await expect( + keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1, undefined, [ + BtcScope.Mainnet, + ]), + ), + ).rejects.toThrow( + `Unsupported scopes for WatchOnlyKeyring: ${BtcScope.Mainnet}. Supported scopes: ${EthScope.Eoa}.`, + ); + }); + + it('throws for empty scopes', async () => { + await expect( + keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1, undefined, []), + ), + ).rejects.toThrow('Scopes must not be empty'); + }); + }); + + describe('getAccount', () => { + it('returns the account matching the given ID', async () => { + const [createdAccount] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + + const account = await keyring.getAccount(createdAccount?.id as string); + + expect(account).toStrictEqual(createdAccount); + }); + + it('throws when no account matches the given ID', async () => { + await expect( + keyring.getAccount('00000000-0000-0000-0000-000000000000'), + ).rejects.toThrow( + 'Account not found for id: 00000000-0000-0000-0000-000000000000', + ); + }); + }); + + describe('lookupAccount', () => { + it('returns the account matching the given ID', async () => { + const [createdAccount] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + + expect(keyring.lookupAccount(createdAccount?.id as string)).toStrictEqual( + createdAccount, + ); + }); + + it('returns undefined when no account matches the given ID', async () => { + expect( + keyring.lookupAccount('00000000-0000-0000-0000-000000000000'), + ).toBeUndefined(); + }); + }); + + describe('lookupByAddress', () => { + it('returns the account matching the given address', async () => { + const [createdAccount] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + + expect(keyring.lookupByAddress(TEST_ADDRESS_1_CHECKSUMMED)).toStrictEqual( + createdAccount, + ); + }); + + it('matches the address case-insensitively', async () => { + const [createdAccount] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + + expect(keyring.lookupByAddress(TEST_ADDRESS_1)).toStrictEqual( + createdAccount, + ); + }); + + it('returns undefined when no account matches the given address', async () => { + await keyring.createAccounts(createAddressImportOptions(TEST_ADDRESS_1)); + + expect(keyring.lookupByAddress(TEST_ADDRESS_2)).toBeUndefined(); + }); + + it('returns undefined when the keyring holds no accounts', () => { + expect(keyring.lookupByAddress(TEST_ADDRESS_1)).toBeUndefined(); + }); + }); + + describe('deleteAccount', () => { + it('deletes the account matching the given ID', async () => { + const [createdAccount] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + + await keyring.deleteAccount(createdAccount?.id as string); + + expect(await keyring.getAccounts()).toStrictEqual([]); + }); + + it('re-creating a deleted account preserves the account ID', async () => { + const [createdAccount] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + + await keyring.deleteAccount(createdAccount?.id as string); + const [recreatedAccount] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + + expect(recreatedAccount?.id).toBe(createdAccount?.id); + }); + + it('throws when no account matches the given ID', async () => { + await expect( + keyring.deleteAccount('00000000-0000-0000-0000-000000000000'), + ).rejects.toThrow( + 'Account not found for id: 00000000-0000-0000-0000-000000000000', + ); + }); + }); + + describe('serialize', () => { + it('serializes an empty keyring', async () => { + expect(await keyring.serialize()).toStrictEqual({ accounts: [] }); + }); + + it('serializes the imported accounts', async () => { + await keyring.createAccounts(createAddressImportOptions(TEST_ADDRESS_1)); + await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_2, EthAccountType.Erc4337), + ); + + expect(await keyring.serialize()).toStrictEqual({ + accounts: [ + { + type: EthAccountType.Eoa, + address: TEST_ADDRESS_1_CHECKSUMMED, + scopes: [EthScope.Eoa], + }, + { + type: EthAccountType.Erc4337, + address: getChecksumAddress(TEST_ADDRESS_2), + scopes: [EthScope.Eoa], + }, + ], + }); + }); + }); + + describe('deserialize', () => { + it('restores accounts from a serialized state', async () => { + await keyring.deserialize({ + accounts: [ + { + type: EthAccountType.Eoa, + address: TEST_ADDRESS_1_CHECKSUMMED, + scopes: [EthScope.Eoa], + }, + ], + }); + + const accounts = await keyring.getAccounts(); + + expect(accounts).toHaveLength(1); + expect(accounts[0]?.address).toBe(TEST_ADDRESS_1_CHECKSUMMED); + expect(accounts[0]?.type).toBe(EthAccountType.Eoa); + expect(accounts[0]?.scopes).toStrictEqual([EthScope.Eoa]); + }); + + it('normalizes addresses from a serialized state', async () => { + await keyring.deserialize({ + accounts: [ + { + type: EthAccountType.Eoa, + address: TEST_ADDRESS_1, + scopes: [EthScope.Eoa], + }, + ], + }); + + const accounts = await keyring.getAccounts(); + + expect(accounts[0]?.address).toBe(TEST_ADDRESS_1_CHECKSUMMED); + }); + + it('restores non-default account types', async () => { + await keyring.deserialize({ + accounts: [ + { + type: EthAccountType.Erc4337, + address: TEST_ADDRESS_1_CHECKSUMMED, + scopes: [EthScope.Eoa], + }, + ], + }); + + const accounts = await keyring.getAccounts(); + + expect(accounts[0]?.type).toBe(EthAccountType.Erc4337); + }); + + it('restores specific EVM scopes from a serialized state', async () => { + await keyring.deserialize({ + accounts: [ + { + type: EthAccountType.Eoa, + address: TEST_ADDRESS_1_CHECKSUMMED, + scopes: [EthScope.Mainnet], + }, + ], + }); + + const accounts = await keyring.getAccounts(); + + expect(accounts[0]?.scopes).toStrictEqual([EthScope.Mainnet]); + }); + + it('round-trips through serialize', async () => { + await keyring.createAccounts(createAddressImportOptions(TEST_ADDRESS_1)); + await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_2, EthAccountType.Erc4337), + ); + + const state = await keyring.serialize(); + + const otherKeyring = new WatchOnlyKeyring(); + await otherKeyring.deserialize(state); + + expect(await otherKeyring.getAccounts()).toStrictEqual( + await keyring.getAccounts(), + ); + }); + + it('leaves the existing state intact when deserialization fails mid-batch', async () => { + await keyring.createAccounts(createAddressImportOptions(TEST_ADDRESS_1)); + const originalAccounts = await keyring.getAccounts(); + + await expect( + keyring.deserialize({ + accounts: [ + { + type: EthAccountType.Eoa, + address: TEST_ADDRESS_2, + scopes: [EthScope.Eoa], + }, + { + type: EthAccountType.Eoa, + address: 'not-an-address', + scopes: [EthScope.Eoa], + }, + ], + }), + ).rejects.toThrow('Invalid EVM address: not-an-address'); + + expect(await keyring.getAccounts()).toStrictEqual(originalAccounts); + }); + + it('replaces any existing state', async () => { + await keyring.createAccounts(createAddressImportOptions(TEST_ADDRESS_1)); + + await keyring.deserialize({ + accounts: [ + { + type: EthAccountType.Eoa, + address: TEST_ADDRESS_2, + scopes: [EthScope.Eoa], + }, + ], + }); + + const accounts = await keyring.getAccounts(); + + expect(accounts).toHaveLength(1); + expect(accounts[0]?.address).toBe(getChecksumAddress(TEST_ADDRESS_2)); + }); + + it('handles an empty account list', async () => { + await keyring.deserialize({ accounts: [] }); + + expect(await keyring.getAccounts()).toStrictEqual([]); + }); + + it('de-duplicates repeated addresses', async () => { + await keyring.deserialize({ + accounts: [ + { + type: EthAccountType.Eoa, + address: TEST_ADDRESS_1, + scopes: [EthScope.Eoa], + }, + { + type: EthAccountType.Eoa, + address: TEST_ADDRESS_1_CHECKSUMMED, + scopes: [EthScope.Eoa], + }, + ], + }); + + expect(await keyring.getAccounts()).toHaveLength(1); + }); + + it('throws for an invalid state', async () => { + await expect(keyring.deserialize({})).rejects.toThrow( + 'At path: accounts -- Expected an array value, but received: undefined', + ); + await expect(keyring.deserialize('invalid')).rejects.toThrow( + 'Expected an object, but received: "invalid"', + ); + }); + + it('throws for a state entry missing the account type', async () => { + await expect( + keyring.deserialize({ accounts: [{ address: TEST_ADDRESS_1 }] }), + ).rejects.toThrow(/At path: accounts\.0\.type/u); + }); + + it('throws for a state entry missing scopes', async () => { + await expect( + keyring.deserialize({ + accounts: [ + { type: EthAccountType.Eoa, address: TEST_ADDRESS_1_CHECKSUMMED }, + ], + }), + ).rejects.toThrow(/At path: accounts\.0\.scopes/u); + }); + + it('throws for an invalid address in the state', async () => { + await expect( + keyring.deserialize({ + accounts: [ + { + type: EthAccountType.Eoa, + address: 'not-an-address', + scopes: [EthScope.Eoa], + }, + ], + }), + ).rejects.toThrow('Invalid EVM address: not-an-address'); + }); + + it('throws for a non-EVM account type in the state', async () => { + await expect( + keyring.deserialize({ + accounts: [ + { + type: 'bip122:p2pkh', + address: TEST_ADDRESS_1_CHECKSUMMED, + scopes: [EthScope.Eoa], + }, + ], + }), + ).rejects.toThrow( + "Unsupported account type for WatchOnlyKeyring: bip122:p2pkh. Only 'eip155:eoa' and 'eip155:erc4337' are supported.", + ); + }); + + it('throws for unsupported scopes in the state', async () => { + await expect( + keyring.deserialize({ + accounts: [ + { + type: EthAccountType.Eoa, + address: TEST_ADDRESS_1_CHECKSUMMED, + scopes: [BtcScope.Mainnet], + }, + ], + }), + ).rejects.toThrow( + `Unsupported scopes for WatchOnlyKeyring: ${BtcScope.Mainnet}. Supported scopes: ${EthScope.Eoa}.`, + ); + }); + }); + + describe('submitRequest', () => { + it('always throws', async () => { + const [account] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + + await expect( + keyring.submitRequest(createMockRequest(account?.id as string)), + ).rejects.toThrow( + 'WatchOnlyKeyring cannot handle requests: watch-only accounts have no signing capability', + ); + }); + }); +}); diff --git a/packages/keyring-watch-only/src/watch-only-keyring.ts b/packages/keyring-watch-only/src/watch-only-keyring.ts new file mode 100644 index 000000000..e78f80efa --- /dev/null +++ b/packages/keyring-watch-only/src/watch-only-keyring.ts @@ -0,0 +1,443 @@ +import { + AccountCreationType, + CaipChainIdStruct, + EthAccountType, + EthScope, + isEvmAccountType, + KeyringAccountTypeStruct, +} from '@metamask/keyring-api'; +import type { + CaipChainId, + KeyringAccount, + KeyringAccountType, + KeyringRequest, +} from '@metamask/keyring-api'; +import { + assertCreateAccountOptionIsSupported, + KeyringType, +} from '@metamask/keyring-api/v2'; +import type { + CreateAccountOptions, + Keyring, + KeyringCapabilities, +} from '@metamask/keyring-api/v2'; +import { + generateEthAccountId, + KeyringAccountRegistry, +} from '@metamask/keyring-sdk'; +import { isScopeEqualToAny } from '@metamask/keyring-utils'; +import type { AccountId } from '@metamask/keyring-utils'; +import { array, assert, nonempty, object, string } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { add0x, getChecksumAddress, isValidHexAddress } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; +import { Mutex } from 'async-mutex'; + +/** + * Capabilities for the WatchOnlyKeyring. + * + * Watch-only accounts hold no secret material, so the keyring exposes no + * signing or export capabilities. Accounts declare no methods, meaning any + * signing request targeting them fails. + */ +const WATCH_ONLY_KEYRING_CAPABILITIES: KeyringCapabilities = { + scopes: [EthScope.Eoa], + address: { + import: true, + }, +}; + +/** + * Struct for a serialized watch-only account state entry. + * + * The entry is self-describing: the account type and scopes are always + * stored, so persisted state never relies on implicit defaults. + */ +const WatchOnlyAccountStateStruct = object({ + /** + * The account type, matching {@link KeyringAccount.type}. + */ + type: KeyringAccountTypeStruct, + + /** + * The account address. + */ + address: string(), + + /** + * The account scopes (CAIP-2 chain IDs), matching + * {@link KeyringAccount.scopes}. + */ + scopes: nonempty(array(CaipChainIdStruct)), +}); + +/** + * Serialized state entry of a single watch-only account. + */ +export type WatchOnlyAccountState = Infer; + +/** + * Struct for {@link WatchOnlyKeyringState}. + */ +const WatchOnlyKeyringStateStruct = object({ + /** + * The watch-only accounts held by the keyring. + */ + accounts: array(WatchOnlyAccountStateStruct), +}); + +/** + * Serialized state of the WatchOnlyKeyring. + * + * Only the source of truth (the imported addresses and their scopes) is + * persisted; account objects are rebuilt from it on `deserialize`. + */ +export type WatchOnlyKeyringState = Infer; + +/** + * Check if a given keyring is a watch-only keyring. + * + * @param keyring - The keyring to check. + * @returns True if the keyring is a watch-only keyring, false otherwise. + */ +export function isWatchOnlyKeyring( + keyring: Keyring, +): keyring is WatchOnlyKeyring { + return keyring.type === KeyringType.WatchOnly; +} + +/** + * A keyring that holds watch-only accounts imported by address. + * + * Watch-only accounts carry no secret material: the keyring cannot sign, and + * `submitRequest` always throws. It exists so that addresses can be tracked + * as first-class account objects without any signing capability. + * + * Account creation is only supported through the `address:import` option. + * Creating an account from an address that is already held by the keyring is + * idempotent: the existing account is returned. + */ +export class WatchOnlyKeyring implements Keyring { + static readonly type = `${KeyringType.WatchOnly}` as const; + + readonly type = `${KeyringType.WatchOnly}` as const; + + readonly capabilities: KeyringCapabilities = WATCH_ONLY_KEYRING_CAPABILITIES; + + readonly #registry: KeyringAccountRegistry = new KeyringAccountRegistry({ + generateId: generateEthAccountId, + }); + + /** + * Mutex to ensure exclusive access to the keyring state during operations + * that mutate it. + */ + readonly #lock = new Mutex(); + + /** + * Resolve the scopes of an imported account. + * + * When no scopes are provided, the keyring's own scopes are used: the scope + * cannot always be detected from the address alone (an EVM address is valid + * on every EVM chain). Provided scopes must be non-empty and supported by + * the keyring, where the special `eip155:0` scope (any EVM chain) matches + * any `eip155:*` chain ID. + * + * @param scopes - The scopes to resolve, if any. + * @returns The resolved scopes. + * @throws If the provided scopes are empty or unsupported by the keyring. + */ + #resolveScopes(scopes: readonly CaipChainId[] | undefined): CaipChainId[] { + if (scopes === undefined) { + return [...this.capabilities.scopes]; + } + + if (scopes.length === 0) { + throw new Error('Scopes must not be empty'); + } + + const supportedScopes = this.capabilities.scopes; + const unsupportedScopes = scopes.filter( + (scope) => !isScopeEqualToAny(scope, supportedScopes), + ); + + if (unsupportedScopes.length > 0) { + throw new Error( + `Unsupported scopes for WatchOnlyKeyring: ${unsupportedScopes.join(', ')}. Supported scopes: ${supportedScopes.join(', ')}.`, + ); + } + + return [...scopes]; + } + + /** + * Build a valid {@link KeyringAccount} from raw inputs without touching the + * registry. The address is validated and normalized; type and scopes are + * resolved to their defaults when absent. The account ID is derived + * deterministically from the checksummed address via {@link generateEthAccountId}. + * + * @param address - The address to import. + * @param accountType - The account type to use, defaulting to `eip155:eoa`. + * @param scopes - The scopes to use, defaulting to the keyring's scopes. + * @returns A fully-formed KeyringAccount ready to be stored. + * @throws If the address is not a valid EVM address, the account type is + * not an EVM account type, or the scopes are unsupported. + */ + #toAccount( + address: string, + accountType: KeyringAccountType | undefined, + scopes: readonly CaipChainId[] | undefined, + ): KeyringAccount { + const hexAddress = add0x(address); + + if (!isValidHexAddress(hexAddress)) { + throw new Error(`Invalid EVM address: ${address}`); + } + + const checksumAddress = getChecksumAddress(hexAddress); + const resolvedAccountType = accountType ?? EthAccountType.Eoa; + const resolvedScopes = this.#resolveScopes(scopes); + + if (!isEvmAccountType(resolvedAccountType)) { + throw new Error( + `Unsupported account type for WatchOnlyKeyring: ${resolvedAccountType}. Only '${EthAccountType.Eoa}' and '${EthAccountType.Erc4337}' are supported.`, + ); + } + + return { + // NOTE: The registry also uses this function to generate ID here. + id: generateEthAccountId(checksumAddress), + type: resolvedAccountType, + address: checksumAddress, + scopes: resolvedScopes, + methods: [], + options: {}, + }; + } + + /** + * Get or create the account for the given address. + * + * Creating an account for an already-held address returns the existing + * account (idempotency), provided the type and scopes are compatible. + * + * @param address - The address to import. + * @param accountType - The account type to use, defaulting to `eip155:eoa`. + * @param scopes - The scopes to use, defaulting to the keyring's scopes. + * @returns The account for the given address. + * @throws If the address is not a valid EVM address, the account type is + * not an EVM account type, the scopes are unsupported, or the address is + * already registered with an incompatible type or scopes. + */ + #getOrCreateAccount( + address: string, + accountType: KeyringAccountType | undefined, + scopes: readonly CaipChainId[] | undefined, + ): KeyringAccount { + const account = this.#toAccount(address, accountType, scopes); + + const existingAccount = this.#registry.get(account.id); + if (existingAccount) { + if (existingAccount.type !== account.type) { + throw new Error( + `Account already exists with type '${existingAccount.type}', got '${account.type}'.`, + ); + } + + // Every requested scope must be covered by the existing account's scopes. + // `isScopeEqualToAny` handles the `eip155:0` wildcard: an existing + // account with `eip155:0` covers any `eip155:` request. + const hasIncompatibleScopes = account.scopes.some( + (scope) => !isScopeEqualToAny(scope, existingAccount.scopes), + ); + if (hasIncompatibleScopes) { + throw new Error( + `Account already exists with scopes [${existingAccount.scopes.join(', ')}], got [${account.scopes.join(', ')}].`, + ); + } + + return existingAccount; + } + + this.#registry.set(account); + return account; + } + + /** + * Returns all accounts managed by the keyring. + * + * @returns A promise that resolves to an array of all accounts managed by + * this keyring. + */ + async getAccounts(): Promise { + return this.#registry.values(); + } + + /** + * Returns the account with the specified ID. + * + * @param accountId - ID of the account to retrieve. + * @returns A promise that resolves to the account with the given ID. + * @throws If no account matches the given ID. + */ + async getAccount(accountId: AccountId): Promise { + const account = this.lookupAccount(accountId); + + if (!account) { + throw new Error(`Account not found for id: ${accountId}`); + } + + return account; + } + + /** + * Creates a new watch-only account from an imported address. + * + * Importing an address that is already held by the keyring is idempotent: + * the existing account is returned. + * + * @param options - Options describing how to create the account. + * @returns A promise that resolves to an array with the created account. + * @throws If the creation options are unsupported, the address is not a + * valid EVM address, the account type is not an EVM account type, or the + * scopes are unsupported. + */ + async createAccounts( + options: CreateAccountOptions, + ): Promise { + assertCreateAccountOptionIsSupported(options, [ + `${AccountCreationType.AddressImport}`, + ] as const); + + const { address, accountType, scopes } = options; + + return this.#withLock(async () => { + return [this.#getOrCreateAccount(address, accountType, scopes)]; + }); + } + + /** + * Deletes the account with the specified ID. + * + * @param accountId - ID of the account to delete. + * @returns A promise that resolves when the account has been deleted. + * @throws If no account matches the given ID. + */ + async deleteAccount(accountId: AccountId): Promise { + return this.#withLock(async () => { + const account = this.#registry.get(accountId); + + if (!account) { + throw new Error(`Account not found for id: ${accountId}`); + } + + this.#registry.delete(accountId); + }); + } + + /** + * Serializes the keyring state to a JSON object. + * + * @returns A promise that resolves to a JSON-serializable representation of + * the keyring state. + */ + async serialize(): Promise { + const state: WatchOnlyKeyringState = { + accounts: this.#registry.values().map((account) => ({ + type: account.type, + address: account.address, + scopes: account.scopes, + })), + }; + + return state; + } + + /** + * Restores the keyring state from a serialized JSON object. + * + * Replaces any existing state with the deserialized accounts. + * + * @param state - A JSON object representing a serialized keyring state. + * @returns A promise that resolves when the keyring state has been restored. + * @throws If the state is invalid, contains an invalid EVM address, a + * non-EVM account type, or unsupported scopes. + */ + async deserialize(state: Json): Promise { + return this.#withLock(async () => { + assert(state, WatchOnlyKeyringStateStruct); + + // Validate and build all accounts before mutating the registry so that + // a single invalid entry does not leave the keyring in a partial state. + const accounts = state.accounts.map(({ type, address, scopes }) => + this.#toAccount(address, type, scopes), + ); + + this.#registry.clear(); + for (const account of accounts) { + this.#registry.set(account); + } + }); + } + + /** + * Submits a request to the keyring. + * + * Watch-only accounts cannot handle requests: the keyring holds no secret + * material and has no signing capability. + * + * @param _request - The `KeyringRequest` object to submit. + * @returns This method always throws. + * @throws Always, since watch-only accounts cannot handle requests. + */ + async submitRequest(_request: KeyringRequest): Promise { + throw new Error( + 'WatchOnlyKeyring cannot handle requests: watch-only accounts have no signing capability', + ); + } + + /** + * Get an account by its ID, synchronously. + * + * @param accountId - The account ID to look up. + * @returns The account, or `undefined` if not found. + */ + lookupAccount(accountId: AccountId): KeyringAccount | undefined { + return this.#registry.get(accountId); + } + + /** + * Get an account by its address (case-insensitive), synchronously. + * + * Performs an O(1) exact lookup first; falls back to a linear scan to + * handle addresses passed with a different casing (e.g. lowercase vs + * EIP-55 checksummed). All addresses held by the keyring are valid EVM + * addresses, so the fallback comparison is safe. + * + * @param address - The address to look up. + * @returns The account, or `undefined` if not found. + */ + lookupByAddress(address: string): KeyringAccount | undefined { + const accountId = this.#registry.getAccountId(address); + + if (accountId !== undefined) { + return this.#registry.get(accountId); + } + + return this.#registry + .values() + .find( + (account) => account.address.toLowerCase() === address.toLowerCase(), + ); + } + + /** + * Execute an operation with exclusive access to the keyring state. + * + * @param callback - A function that performs the operation. + * @returns The result of the callback. + */ + async #withLock(callback: () => Promise): Promise { + return this.#lock.runExclusive(callback); + } +} diff --git a/packages/keyring-watch-only/tsconfig.build.json b/packages/keyring-watch-only/tsconfig.build.json new file mode 100644 index 000000000..cda0cf02a --- /dev/null +++ b/packages/keyring-watch-only/tsconfig.build.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "dist", + "rootDir": "src", + "exactOptionalPropertyTypes": false + }, + "references": [ + { + "path": "../keyring-api/tsconfig.build.json" + }, + { + "path": "../keyring-sdk/tsconfig.build.json" + }, + { + "path": "../keyring-utils/tsconfig.build.json" + } + ], + "include": ["./src/**/*.ts"], + "exclude": ["./src/**/*.test.ts"] +} diff --git a/packages/keyring-watch-only/tsconfig.json b/packages/keyring-watch-only/tsconfig.json new file mode 100644 index 000000000..c066fdeb8 --- /dev/null +++ b/packages/keyring-watch-only/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "exactOptionalPropertyTypes": false, + "target": "es2017" + }, + "references": [ + { "path": "../keyring-api" }, + { "path": "../keyring-sdk" }, + { "path": "../keyring-utils" } + ], + "include": ["./src"], + "exclude": ["./dist/**/*"] +} diff --git a/yarn.lock b/yarn.lock index 96390f560..a09280a15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2813,6 +2813,27 @@ __metadata: languageName: node linkType: hard +"@metamask/watch-only-keyring@workspace:packages/keyring-watch-only": + version: 0.0.0-use.local + resolution: "@metamask/watch-only-keyring@workspace:packages/keyring-watch-only" + dependencies: + "@lavamoat/allow-scripts": "npm:^3.2.1" + "@lavamoat/preinstall-always-fail": "npm:^2.1.0" + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/keyring-api": "npm:^24.1.0" + "@metamask/keyring-sdk": "npm:^3.1.0" + "@metamask/keyring-utils": "npm:^5.0.0" + "@metamask/superstruct": "npm:^3.4.1" + "@metamask/utils": "npm:^11.11.0" + "@ts-bridge/cli": "npm:^0.6.3" + "@types/jest": "npm:^29.5.12" + async-mutex: "npm:^0.5.0" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.5.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@mobily/ts-belt@npm:^3.13.1": version: 3.13.1 resolution: "@mobily/ts-belt@npm:3.13.1"