From 22ea7579243abedeeadbc5a87319f49ed0a30451 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 30 Sep 2026 14:08:24 +0200 Subject: [PATCH 01/13] feat(keying-watch-only): add keyring-watch-only (EVM only) --- README.md | 5 + packages/keyring-api/CHANGELOG.md | 1 + .../keyring-api/src/v2/api/keyring-type.ts | 6 + packages/keyring-watch-only/CHANGELOG.md | 20 + packages/keyring-watch-only/LICENSE | 15 + packages/keyring-watch-only/README.md | 93 ++++ packages/keyring-watch-only/jest.config.js | 19 + packages/keyring-watch-only/package.json | 84 ++++ packages/keyring-watch-only/src/index.ts | 2 + .../src/watch-only-keyring-v1-adapter.test.ts | 184 +++++++ .../src/watch-only-keyring-v1-adapter.ts | 70 +++ .../src/watch-only-keyring.test.ts | 473 ++++++++++++++++++ .../src/watch-only-keyring.ts | 320 ++++++++++++ .../keyring-watch-only/tsconfig.build.json | 22 + packages/keyring-watch-only/tsconfig.json | 15 + yarn.lock | 21 + 16 files changed, 1350 insertions(+) create mode 100644 packages/keyring-watch-only/CHANGELOG.md create mode 100644 packages/keyring-watch-only/LICENSE create mode 100644 packages/keyring-watch-only/README.md create mode 100644 packages/keyring-watch-only/jest.config.js create mode 100644 packages/keyring-watch-only/package.json create mode 100644 packages/keyring-watch-only/src/index.ts create mode 100644 packages/keyring-watch-only/src/watch-only-keyring-v1-adapter.test.ts create mode 100644 packages/keyring-watch-only/src/watch-only-keyring-v1-adapter.ts create mode 100644 packages/keyring-watch-only/src/watch-only-keyring.test.ts create mode 100644 packages/keyring-watch-only/src/watch-only-keyring.ts create mode 100644 packages/keyring-watch-only/tsconfig.build.json create mode 100644 packages/keyring-watch-only/tsconfig.json 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..b3a21d164 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 ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) ## [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-watch-only/CHANGELOG.md b/packages/keyring-watch-only/CHANGELOG.md new file mode 100644 index 000000000..5151c27ce --- /dev/null +++ b/packages/keyring-watch-only/CHANGELOG.md @@ -0,0 +1,20 @@ +# 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] + +## [0.1.0] + +### Added + +- Initial release of `@metamask/watch-only-keyring` ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) + - A watch-only v2 [`Keyring`](https://github.com/MetaMask/accounts/tree/main/packages/keyring-api) 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`). + - Includes `WatchOnlyKeyringV1Adapter`, which adapts the keyring to the legacy v1 keyring API for `KeyringController` compatibility, adding address-based account removal. + +[Unreleased]: https://github.com/MetaMask/accounts/compare/@metamask/watch-only-keyring@0.1.0...HEAD +[0.1.0]: https://github.com/MetaMask/accounts/releases/tag/@metamask/watch-only-keyring@0.1.0 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..f904939e8 --- /dev/null +++ b/packages/keyring-watch-only/README.md @@ -0,0 +1,93 @@ +# 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` + +## Usage + +```ts +import { WatchOnlyKeyring } from '@metamask/watch-only-keyring'; + +const keyring = new WatchOnlyKeyring(); + +// Import an address. `accountType` is optional and defaults to `eip155:eoa`. +const [account] = await keyring.createAccounts({ + type: 'address:import', + address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', +}); + +// account: { +// id: '…', +// type: 'eip155:eoa', +// address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', +// scopes: ['eip155:0'], +// methods: [], +// options: {}, +// } +``` + +Importing an address that is already held by the keyring is idempotent: the +existing account is returned. Account IDs are deterministic (derived from the +address), so they survive state resets. + +### Capabilities + +```ts +keyring.capabilities; +// { +// scopes: ['eip155:0'], +// address: { import: true }, +// } +``` + +### Legacy v1 adapter + +`KeyringController` interacts with keyrings through the legacy v1 interface. +Use `WatchOnlyKeyringV1Adapter` to expose the watch-only keyring to it: + +```ts +import { WatchOnlyKeyringV1Adapter } from '@metamask/watch-only-keyring'; + +const adapter = new WatchOnlyKeyringV1Adapter(keyring); +``` + +The inherited signing methods always throw for watch-only accounts (they +declare no methods), as does `exportAccount`. The adapter adds +address-based account removal: + +```ts +await adapter.removeAccount('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); +``` + +## Contributing + +### Setup + +- Install [Node.js](https://nodejs.org) version 22 + - If you are using [nvm](https://github.com/creationix/nvm#installation) (recommended) running `nvm use` will automatically choose the right node version for you. +- Install [Yarn v4](https://yarnpkg.com/getting-started/install) +- Run `yarn install` to install dependencies and run any required post-install scripts + +### Testing and Linting + +Run `yarn test` to run the tests once. + +Run `yarn lint` to run the linter, or run `yarn lint:fix` to run the linter and fix any automatically fixable issues. 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..5c467eb0c --- /dev/null +++ b/packages/keyring-watch-only/src/watch-only-keyring-v1-adapter.test.ts @@ -0,0 +1,184 @@ +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 + >; + }; +}; + +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'); + + return { + adapter: new WatchOnlyKeyringV1Adapter(inner), + inner, + mocks: { deleteAccount }, + }; +} + +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(); + + await adapter.removeAccount(TEST_ADDRESS_1); + + const accounts = await inner.getAccounts(); + expect(accounts).toStrictEqual([]); + expect(mocks.deleteAccount).toHaveBeenCalledTimes(1); + + const deletedAccountId = mocks.deleteAccount.mock.calls[0]?.[0]; + expect(deletedAccountId).toBeDefined(); + }); + + 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 '${TEST_ADDRESS_1}' 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..7148fa4f4 --- /dev/null +++ b/packages/keyring-watch-only/src/watch-only-keyring-v1-adapter.ts @@ -0,0 +1,70 @@ +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 { add0x } from '@metamask/utils'; + +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 normalizedAddress = add0x(address).toLowerCase(); + + const accounts = await this.inner.getAccounts(); + const account = accounts.find( + (candidate) => + add0x(candidate.address).toLowerCase() === normalizedAddress, + ); + + if (!account) { + throw new Error(`Account '${address}' 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..6912a7de0 --- /dev/null +++ b/packages/keyring-watch-only/src/watch-only-keyring.test.ts @@ -0,0 +1,473 @@ +import { + AccountCreationType, + 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, +): CreateAccountOptions { + return { + type: AccountCreationType.AddressImport, + address, + ...(accountType ? { accountType } : {}), + } 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('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.", + ); + }); + }); + + 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('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, + }, + { + type: EthAccountType.Erc4337, + address: getChecksumAddress(TEST_ADDRESS_2), + }, + ], + }); + }); + }); + + describe('deserialize', () => { + it('restores accounts from a serialized state', async () => { + await keyring.deserialize({ + accounts: [ + { type: EthAccountType.Eoa, address: TEST_ADDRESS_1_CHECKSUMMED }, + ], + }); + + 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); + }); + + it('normalizes addresses from a serialized state', async () => { + await keyring.deserialize({ + accounts: [{ type: EthAccountType.Eoa, address: TEST_ADDRESS_1 }], + }); + + 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, + }, + ], + }); + + const accounts = await keyring.getAccounts(); + + expect(accounts[0]?.type).toBe(EthAccountType.Erc4337); + }); + + 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('replaces any existing state', async () => { + await keyring.createAccounts(createAddressImportOptions(TEST_ADDRESS_1)); + + await keyring.deserialize({ + accounts: [{ type: EthAccountType.Eoa, address: TEST_ADDRESS_2 }], + }); + + 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 }, + { type: EthAccountType.Eoa, address: TEST_ADDRESS_1_CHECKSUMMED }, + ], + }); + + 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 an invalid address in the state', async () => { + await expect( + keyring.deserialize({ + accounts: [{ type: EthAccountType.Eoa, address: 'not-an-address' }], + }), + ).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, + }, + ], + }), + ).rejects.toThrow( + "Unsupported account type for WatchOnlyKeyring: bip122:p2pkh. Only 'eip155:eoa' and 'eip155:erc4337' are supported.", + ); + }); + }); + + 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..1aa1ca04a --- /dev/null +++ b/packages/keyring-watch-only/src/watch-only-keyring.ts @@ -0,0 +1,320 @@ +import { + AccountCreationType, + EthAccountType, + EthScope, + isEvmAccountType, + KeyringAccountTypeStruct, +} from '@metamask/keyring-api'; +import type { + 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 type { AccountId } from '@metamask/keyring-utils'; +import { array, assert, 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 is 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(), +}); + +/** + * 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) 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(); + + /** + * Get or create the account for the given address. + * + * The address is validated as an EVM address and normalized to its EIP-55 + * checksum representation. Creating an account for an already-held address + * returns the existing account (idempotency). + * + * @param address - The address to import. + * @param accountType - The account type to use, defaulting to `eip155:eoa`. + * @returns The account for the given address. + * @throws If the address is not a valid EVM address or the account type is + * not an EVM account type. + */ + #getOrCreateAccount( + address: string, + accountType: KeyringAccountType | 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; + + if (!isEvmAccountType(resolvedAccountType)) { + throw new Error( + `Unsupported account type for WatchOnlyKeyring: ${resolvedAccountType}. Only '${EthAccountType.Eoa}' and '${EthAccountType.Erc4337}' are supported.`, + ); + } + + // Registering an already-held address is idempotent: `register` returns + // the existing account ID. + const id = this.#registry.register(checksumAddress); + + const existingAccount = this.#registry.get(id); + if (existingAccount) { + return existingAccount; + } + + const account: KeyringAccount = { + id, + type: resolvedAccountType, + address: checksumAddress, + scopes: [...this.capabilities.scopes], + methods: [], + options: {}, + }; + + 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.#registry.get(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, or the account type is not an EVM account type. + */ + async createAccounts( + options: CreateAccountOptions, + ): Promise { + assertCreateAccountOptionIsSupported(options, [ + `${AccountCreationType.AddressImport}`, + ] as const); + + const { address, accountType } = options; + + return this.#withLock(async () => { + return [this.#getOrCreateAccount(address, accountType)]; + }); + } + + /** + * 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, + })), + }; + + 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, or + * contains a non-EVM account type. + */ + async deserialize(state: Json): Promise { + return this.#withLock(async () => { + assert(state, WatchOnlyKeyringStateStruct); + + this.#registry.clear(); + + for (const { type, address } of state.accounts) { + this.#getOrCreateAccount(address, type); + } + }); + } + + /** + * 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', + ); + } + + /** + * 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" From 3412ac868da1db3eaabf692dfee746d457ac6b3f Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 30 Sep 2026 14:13:23 +0200 Subject: [PATCH 02/13] test: restore type test for WatchOnly enum --- packages/keyring-api/src/v2/api/keyring.test-d.ts | 1 + 1 file changed, 1 insertion(+) 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); From 2d4e40940f40cba2eec46faa8fc6e88ba9381207 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 30 Sep 2026 14:16:34 +0200 Subject: [PATCH 03/13] chore: changelog --- packages/keyring-api/CHANGELOG.md | 2 +- packages/keyring-watch-only/CHANGELOG.md | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/keyring-api/CHANGELOG.md b/packages/keyring-api/CHANGELOG.md index b3a21d164..5e3000b51 100644 --- a/packages/keyring-api/CHANGELOG.md +++ b/packages/keyring-api/CHANGELOG.md @@ -13,7 +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 ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) +- Add `KeyringType.WatchOnly` keyring type ([#644](https://github.com/MetaMask/accounts/pull/644)) ## [24.1.0] diff --git a/packages/keyring-watch-only/CHANGELOG.md b/packages/keyring-watch-only/CHANGELOG.md index 5151c27ce..8f357ff9a 100644 --- a/packages/keyring-watch-only/CHANGELOG.md +++ b/packages/keyring-watch-only/CHANGELOG.md @@ -7,14 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.1.0] - ### Added -- Initial release of `@metamask/watch-only-keyring` ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) - - A watch-only v2 [`Keyring`](https://github.com/MetaMask/accounts/tree/main/packages/keyring-api) that holds accounts imported by address, without any signing capability. +- 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`). - Includes `WatchOnlyKeyringV1Adapter`, which adapts the keyring to the legacy v1 keyring API for `KeyringController` compatibility, adding address-based account removal. -[Unreleased]: https://github.com/MetaMask/accounts/compare/@metamask/watch-only-keyring@0.1.0...HEAD -[0.1.0]: https://github.com/MetaMask/accounts/releases/tag/@metamask/watch-only-keyring@0.1.0 +[Unreleased]: https://github.com/MetaMask/accounts/ From f7d84e3bcc14169783af7f09a2387f56f34e0a24 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 30 Sep 2026 14:17:21 +0200 Subject: [PATCH 04/13] ci: add keyring-watch-only to PR titles --- .github/workflows/validate-pr-title.yml | 1 + 1 file changed, 1 insertion(+) 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]).+$' From 9878854cf88a2f881f0f0393f800f1f276518b0f Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 30 Sep 2026 16:14:53 +0200 Subject: [PATCH 05/13] feat: add lookup* sync methods --- packages/keyring-watch-only/CHANGELOG.md | 1 + packages/keyring-watch-only/README.md | 13 +++++ .../src/watch-only-keyring-v1-adapter.test.ts | 18 ++++--- .../src/watch-only-keyring-v1-adapter.ts | 9 +--- .../src/watch-only-keyring.test.ts | 50 +++++++++++++++++++ .../src/watch-only-keyring.ts | 42 +++++++++++++++- 6 files changed, 116 insertions(+), 17 deletions(-) diff --git a/packages/keyring-watch-only/CHANGELOG.md b/packages/keyring-watch-only/CHANGELOG.md index 8f357ff9a..727fbc8ee 100644 --- a/packages/keyring-watch-only/CHANGELOG.md +++ b/packages/keyring-watch-only/CHANGELOG.md @@ -13,5 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`). - 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/README.md b/packages/keyring-watch-only/README.md index f904939e8..3446bb28a 100644 --- a/packages/keyring-watch-only/README.md +++ b/packages/keyring-watch-only/README.md @@ -58,6 +58,19 @@ keyring.capabilities; // } ``` +### Synchronous account lookup + +The `AccountsController` needs synchronous access to accounts. The keyring +exposes two sync lookup methods alongside the async `Keyring` API: + +```ts +keyring.lookupAccount(account.id); +// → the account, or undefined + +keyring.lookupByAddress('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); +// → the account, or undefined (address matching is case-insensitive) +``` + ### Legacy v1 adapter `KeyringController` interacts with keyrings through the legacy v1 interface. 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 index 5c467eb0c..e08620e89 100644 --- 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 @@ -27,6 +27,10 @@ type SetupResult = { ReturnType, Parameters >; + lookupByAddress: jest.SpyInstance< + ReturnType, + Parameters + >; }; }; @@ -43,11 +47,12 @@ async function setup({ } const deleteAccount = jest.spyOn(inner, 'deleteAccount'); + const lookupByAddress = jest.spyOn(inner, 'lookupByAddress'); return { adapter: new WatchOnlyKeyringV1Adapter(inner), inner, - mocks: { deleteAccount }, + mocks: { deleteAccount, lookupByAddress }, }; } @@ -118,14 +123,13 @@ describe('WatchOnlyKeyringV1Adapter', () => { it('removes an account by resolving its address and deleting its account ID', async () => { const { adapter, inner, mocks } = await setup(); - await adapter.removeAccount(TEST_ADDRESS_1); + const account = inner.lookupByAddress(TEST_ADDRESS_1); - const accounts = await inner.getAccounts(); - expect(accounts).toStrictEqual([]); - expect(mocks.deleteAccount).toHaveBeenCalledTimes(1); + await adapter.removeAccount(TEST_ADDRESS_1); - const deletedAccountId = mocks.deleteAccount.mock.calls[0]?.[0]; - expect(deletedAccountId).toBeDefined(); + 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 () => { 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 index 7148fa4f4..592748711 100644 --- a/packages/keyring-watch-only/src/watch-only-keyring-v1-adapter.ts +++ b/packages/keyring-watch-only/src/watch-only-keyring-v1-adapter.ts @@ -1,7 +1,6 @@ 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 { add0x } from '@metamask/utils'; import type { WatchOnlyKeyring } from './watch-only-keyring'; @@ -53,13 +52,7 @@ export class WatchOnlyKeyringV1Adapter extends EthKeyringV1Adapter { - const normalizedAddress = add0x(address).toLowerCase(); - - const accounts = await this.inner.getAccounts(); - const account = accounts.find( - (candidate) => - add0x(candidate.address).toLowerCase() === normalizedAddress, - ); + const account = this.inner.lookupByAddress(address); if (!account) { throw new Error(`Account '${address}' not found`); diff --git a/packages/keyring-watch-only/src/watch-only-keyring.test.ts b/packages/keyring-watch-only/src/watch-only-keyring.test.ts index 6912a7de0..78129d2ab 100644 --- a/packages/keyring-watch-only/src/watch-only-keyring.test.ts +++ b/packages/keyring-watch-only/src/watch-only-keyring.test.ts @@ -273,6 +273,56 @@ describe('WatchOnlyKeyring', () => { }); }); + 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( diff --git a/packages/keyring-watch-only/src/watch-only-keyring.ts b/packages/keyring-watch-only/src/watch-only-keyring.ts index 1aa1ca04a..4604e4aac 100644 --- a/packages/keyring-watch-only/src/watch-only-keyring.ts +++ b/packages/keyring-watch-only/src/watch-only-keyring.ts @@ -199,7 +199,7 @@ export class WatchOnlyKeyring implements Keyring { * @throws If no account matches the given ID. */ async getAccount(accountId: AccountId): Promise { - const account = this.#registry.get(accountId); + const account = this.lookupAccount(accountId); if (!account) { throw new Error(`Account not found for id: ${accountId}`); @@ -301,13 +301,51 @@ export class WatchOnlyKeyring implements Keyring { * @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', ); } + // ────────────────────────────────────────────── + // Synchronous lookup API + // ────────────────────────────────────────────── + + /** + * 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. * From 17a6744f7eccc2fc962dc9c542ddcc46acff213f Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 30 Sep 2026 21:39:50 +0200 Subject: [PATCH 06/13] feat: add scopes support --- packages/keyring-watch-only/CHANGELOG.md | 2 +- packages/keyring-watch-only/README.md | 3 + .../src/watch-only-keyring.test.ts | 149 +++++++++++++++++- .../src/watch-only-keyring.ts | 79 ++++++++-- 4 files changed, 211 insertions(+), 22 deletions(-) diff --git a/packages/keyring-watch-only/CHANGELOG.md b/packages/keyring-watch-only/CHANGELOG.md index 727fbc8ee..4fea6f622 100644 --- a/packages/keyring-watch-only/CHANGELOG.md +++ b/packages/keyring-watch-only/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`). + - 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. diff --git a/packages/keyring-watch-only/README.md b/packages/keyring-watch-only/README.md index 3446bb28a..18c704a8c 100644 --- a/packages/keyring-watch-only/README.md +++ b/packages/keyring-watch-only/README.md @@ -29,6 +29,9 @@ import { WatchOnlyKeyring } from '@metamask/watch-only-keyring'; const keyring = new WatchOnlyKeyring(); // Import an address. `accountType` is optional and defaults to `eip155:eoa`. +// `scopes` is optional and defaults to the keyring's scopes (`['eip155:0']`, +// i.e. any EVM chain), since the scope cannot be detected from an EVM +// address. Specific EVM scopes (e.g. `['eip155:1']`) are also accepted. const [account] = await keyring.createAccounts({ type: 'address:import', address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', diff --git a/packages/keyring-watch-only/src/watch-only-keyring.test.ts b/packages/keyring-watch-only/src/watch-only-keyring.test.ts index 78129d2ab..b635504ce 100644 --- a/packages/keyring-watch-only/src/watch-only-keyring.test.ts +++ b/packages/keyring-watch-only/src/watch-only-keyring.test.ts @@ -1,5 +1,6 @@ import { AccountCreationType, + BtcScope, EthAccountType, EthScope, } from '@metamask/keyring-api'; @@ -22,11 +23,13 @@ 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; } @@ -157,6 +160,43 @@ describe('WatchOnlyKeyring', () => { 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 different scopes', async () => { + const [firstAccount] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1), + ); + const [secondAccount] = await keyring.createAccounts( + createAddressImportOptions(TEST_ADDRESS_1, undefined, [ + EthScope.Mainnet, + ]), + ); + + expect(secondAccount).toStrictEqual(firstAccount); + }); + it('generates deterministic account IDs', async () => { const [account] = await keyring.createAccounts( createAddressImportOptions(TEST_ADDRESS_1), @@ -251,6 +291,26 @@ describe('WatchOnlyKeyring', () => { "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', () => { @@ -372,10 +432,12 @@ describe('WatchOnlyKeyring', () => { { type: EthAccountType.Eoa, address: TEST_ADDRESS_1_CHECKSUMMED, + scopes: [EthScope.Eoa], }, { type: EthAccountType.Erc4337, address: getChecksumAddress(TEST_ADDRESS_2), + scopes: [EthScope.Eoa], }, ], }); @@ -386,7 +448,11 @@ describe('WatchOnlyKeyring', () => { it('restores accounts from a serialized state', async () => { await keyring.deserialize({ accounts: [ - { type: EthAccountType.Eoa, address: TEST_ADDRESS_1_CHECKSUMMED }, + { + type: EthAccountType.Eoa, + address: TEST_ADDRESS_1_CHECKSUMMED, + scopes: [EthScope.Eoa], + }, ], }); @@ -395,11 +461,18 @@ describe('WatchOnlyKeyring', () => { 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 }], + accounts: [ + { + type: EthAccountType.Eoa, + address: TEST_ADDRESS_1, + scopes: [EthScope.Eoa], + }, + ], }); const accounts = await keyring.getAccounts(); @@ -413,6 +486,7 @@ describe('WatchOnlyKeyring', () => { { type: EthAccountType.Erc4337, address: TEST_ADDRESS_1_CHECKSUMMED, + scopes: [EthScope.Eoa], }, ], }); @@ -422,6 +496,22 @@ describe('WatchOnlyKeyring', () => { 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( @@ -442,7 +532,13 @@ describe('WatchOnlyKeyring', () => { await keyring.createAccounts(createAddressImportOptions(TEST_ADDRESS_1)); await keyring.deserialize({ - accounts: [{ type: EthAccountType.Eoa, address: TEST_ADDRESS_2 }], + accounts: [ + { + type: EthAccountType.Eoa, + address: TEST_ADDRESS_2, + scopes: [EthScope.Eoa], + }, + ], }); const accounts = await keyring.getAccounts(); @@ -460,8 +556,16 @@ describe('WatchOnlyKeyring', () => { it('de-duplicates repeated addresses', async () => { await keyring.deserialize({ accounts: [ - { type: EthAccountType.Eoa, address: TEST_ADDRESS_1 }, - { type: EthAccountType.Eoa, address: TEST_ADDRESS_1_CHECKSUMMED }, + { + type: EthAccountType.Eoa, + address: TEST_ADDRESS_1, + scopes: [EthScope.Eoa], + }, + { + type: EthAccountType.Eoa, + address: TEST_ADDRESS_1_CHECKSUMMED, + scopes: [EthScope.Eoa], + }, ], }); @@ -483,10 +587,26 @@ describe('WatchOnlyKeyring', () => { ).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' }], + accounts: [ + { + type: EthAccountType.Eoa, + address: 'not-an-address', + scopes: [EthScope.Eoa], + }, + ], }), ).rejects.toThrow('Invalid EVM address: not-an-address'); }); @@ -498,6 +618,7 @@ describe('WatchOnlyKeyring', () => { { type: 'bip122:p2pkh', address: TEST_ADDRESS_1_CHECKSUMMED, + scopes: [EthScope.Eoa], }, ], }), @@ -505,6 +626,22 @@ describe('WatchOnlyKeyring', () => { "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', () => { diff --git a/packages/keyring-watch-only/src/watch-only-keyring.ts b/packages/keyring-watch-only/src/watch-only-keyring.ts index 4604e4aac..6d35a8e12 100644 --- a/packages/keyring-watch-only/src/watch-only-keyring.ts +++ b/packages/keyring-watch-only/src/watch-only-keyring.ts @@ -1,11 +1,13 @@ import { AccountCreationType, + CaipChainIdStruct, EthAccountType, EthScope, isEvmAccountType, KeyringAccountTypeStruct, } from '@metamask/keyring-api'; import type { + CaipChainId, KeyringAccount, KeyringAccountType, KeyringRequest, @@ -23,8 +25,9 @@ import { generateEthAccountId, KeyringAccountRegistry, } from '@metamask/keyring-sdk'; +import { isScopeEqualToAny } from '@metamask/keyring-utils'; import type { AccountId } from '@metamask/keyring-utils'; -import { array, assert, object, string } from '@metamask/superstruct'; +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'; @@ -47,8 +50,8 @@ const WATCH_ONLY_KEYRING_CAPABILITIES: KeyringCapabilities = { /** * Struct for a serialized watch-only account state entry. * - * The entry is self-describing: the account type is always stored, so - * persisted state never relies on implicit defaults. + * The entry is self-describing: the account type and scopes are always + * stored, so persisted state never relies on implicit defaults. */ const WatchOnlyAccountStateStruct = object({ /** @@ -60,6 +63,12 @@ const WatchOnlyAccountStateStruct = object({ * The account address. */ address: string(), + + /** + * The account scopes (CAIP-2 chain IDs), matching + * {@link KeyringAccount.scopes}. + */ + scopes: nonempty(array(CaipChainIdStruct)), }); /** @@ -80,8 +89,8 @@ const WatchOnlyKeyringStateStruct = object({ /** * Serialized state of the WatchOnlyKeyring. * - * Only the source of truth (the imported addresses) is persisted; account - * objects are rebuilt from it on `deserialize`. + * 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; @@ -125,6 +134,42 @@ export class WatchOnlyKeyring implements Keyring { */ 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]; + } + /** * Get or create the account for the given address. * @@ -134,13 +179,15 @@ export class WatchOnlyKeyring implements Keyring { * * @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 or the account type is - * not an EVM account type. + * @throws If the address is not a valid EVM address, the account type is + * not an EVM account type, or the scopes are unsupported. */ #getOrCreateAccount( address: string, accountType: KeyringAccountType | undefined, + scopes: readonly CaipChainId[] | undefined, ): KeyringAccount { const hexAddress = add0x(address); @@ -171,7 +218,7 @@ export class WatchOnlyKeyring implements Keyring { id, type: resolvedAccountType, address: checksumAddress, - scopes: [...this.capabilities.scopes], + scopes: this.#resolveScopes(scopes), methods: [], options: {}, }; @@ -217,7 +264,8 @@ export class WatchOnlyKeyring implements Keyring { * @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, or the account type is not an EVM account type. + * valid EVM address, the account type is not an EVM account type, or the + * scopes are unsupported. */ async createAccounts( options: CreateAccountOptions, @@ -226,10 +274,10 @@ export class WatchOnlyKeyring implements Keyring { `${AccountCreationType.AddressImport}`, ] as const); - const { address, accountType } = options; + const { address, accountType, scopes } = options; return this.#withLock(async () => { - return [this.#getOrCreateAccount(address, accountType)]; + return [this.#getOrCreateAccount(address, accountType, scopes)]; }); } @@ -263,6 +311,7 @@ export class WatchOnlyKeyring implements Keyring { accounts: this.#registry.values().map((account) => ({ type: account.type, address: account.address, + scopes: account.scopes, })), }; @@ -276,8 +325,8 @@ export class WatchOnlyKeyring implements Keyring { * * @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, or - * contains a non-EVM account type. + * @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 () => { @@ -285,8 +334,8 @@ export class WatchOnlyKeyring implements Keyring { this.#registry.clear(); - for (const { type, address } of state.accounts) { - this.#getOrCreateAccount(address, type); + for (const { type, address, scopes } of state.accounts) { + this.#getOrCreateAccount(address, type, scopes); } }); } From 60a11c120f84f20a400308a71ee9eeee78e7fb49 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 1 Oct 2026 09:52:34 +0200 Subject: [PATCH 07/13] chore: update readme --- packages/keyring-watch-only/README.md | 85 +-------------------------- 1 file changed, 1 insertion(+), 84 deletions(-) diff --git a/packages/keyring-watch-only/README.md b/packages/keyring-watch-only/README.md index 18c704a8c..0e1a934fd 100644 --- a/packages/keyring-watch-only/README.md +++ b/packages/keyring-watch-only/README.md @@ -21,89 +21,6 @@ or `npm install @metamask/watch-only-keyring` -## Usage - -```ts -import { WatchOnlyKeyring } from '@metamask/watch-only-keyring'; - -const keyring = new WatchOnlyKeyring(); - -// Import an address. `accountType` is optional and defaults to `eip155:eoa`. -// `scopes` is optional and defaults to the keyring's scopes (`['eip155:0']`, -// i.e. any EVM chain), since the scope cannot be detected from an EVM -// address. Specific EVM scopes (e.g. `['eip155:1']`) are also accepted. -const [account] = await keyring.createAccounts({ - type: 'address:import', - address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', -}); - -// account: { -// id: '…', -// type: 'eip155:eoa', -// address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', -// scopes: ['eip155:0'], -// methods: [], -// options: {}, -// } -``` - -Importing an address that is already held by the keyring is idempotent: the -existing account is returned. Account IDs are deterministic (derived from the -address), so they survive state resets. - -### Capabilities - -```ts -keyring.capabilities; -// { -// scopes: ['eip155:0'], -// address: { import: true }, -// } -``` - -### Synchronous account lookup - -The `AccountsController` needs synchronous access to accounts. The keyring -exposes two sync lookup methods alongside the async `Keyring` API: - -```ts -keyring.lookupAccount(account.id); -// → the account, or undefined - -keyring.lookupByAddress('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); -// → the account, or undefined (address matching is case-insensitive) -``` - -### Legacy v1 adapter - -`KeyringController` interacts with keyrings through the legacy v1 interface. -Use `WatchOnlyKeyringV1Adapter` to expose the watch-only keyring to it: - -```ts -import { WatchOnlyKeyringV1Adapter } from '@metamask/watch-only-keyring'; - -const adapter = new WatchOnlyKeyringV1Adapter(keyring); -``` - -The inherited signing methods always throw for watch-only accounts (they -declare no methods), as does `exportAccount`. The adapter adds -address-based account removal: - -```ts -await adapter.removeAccount('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); -``` - ## Contributing -### Setup - -- Install [Node.js](https://nodejs.org) version 22 - - If you are using [nvm](https://github.com/creationix/nvm#installation) (recommended) running `nvm use` will automatically choose the right node version for you. -- Install [Yarn v4](https://yarnpkg.com/getting-started/install) -- Run `yarn install` to install dependencies and run any required post-install scripts - -### Testing and Linting - -Run `yarn test` to run the tests once. - -Run `yarn lint` to run the linter, or run `yarn lint:fix` to run the linter and fix any automatically fixable issues. +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/accounts#readme). From c0e5a77526e2e5344784f9b3da488894c5b3fc4d Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 1 Oct 2026 09:53:19 +0200 Subject: [PATCH 08/13] chore: cosmetic --- packages/keyring-watch-only/src/watch-only-keyring.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/keyring-watch-only/src/watch-only-keyring.ts b/packages/keyring-watch-only/src/watch-only-keyring.ts index 6d35a8e12..aa9e15b7f 100644 --- a/packages/keyring-watch-only/src/watch-only-keyring.ts +++ b/packages/keyring-watch-only/src/watch-only-keyring.ts @@ -356,10 +356,6 @@ export class WatchOnlyKeyring implements Keyring { ); } - // ────────────────────────────────────────────── - // Synchronous lookup API - // ────────────────────────────────────────────── - /** * Get an account by its ID, synchronously. * From 71d479ba11bd56450e96933e6c53c3167c0ca88b Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 1 Oct 2026 09:58:11 +0200 Subject: [PATCH 09/13] refactor: use stable error message --- .../src/watch-only-keyring-v1-adapter.test.ts | 2 +- .../keyring-watch-only/src/watch-only-keyring-v1-adapter.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index e08620e89..6fc86cfdd 100644 --- 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 @@ -144,7 +144,7 @@ describe('WatchOnlyKeyringV1Adapter', () => { const { adapter, mocks } = await setup({ addresses: [] }); await expect(adapter.removeAccount(TEST_ADDRESS_1)).rejects.toThrow( - `Account '${TEST_ADDRESS_1}' not found`, + 'Account not found', ); expect(mocks.deleteAccount).not.toHaveBeenCalled(); }); 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 index 592748711..6cb358ff1 100644 --- a/packages/keyring-watch-only/src/watch-only-keyring-v1-adapter.ts +++ b/packages/keyring-watch-only/src/watch-only-keyring-v1-adapter.ts @@ -55,7 +55,7 @@ export class WatchOnlyKeyringV1Adapter extends EthKeyringV1Adapter Date: Thu, 1 Oct 2026 12:57:34 +0200 Subject: [PATCH 10/13] refactor: resolve scopes before registering --- packages/keyring-watch-only/src/watch-only-keyring.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/keyring-watch-only/src/watch-only-keyring.ts b/packages/keyring-watch-only/src/watch-only-keyring.ts index aa9e15b7f..b5173d127 100644 --- a/packages/keyring-watch-only/src/watch-only-keyring.ts +++ b/packages/keyring-watch-only/src/watch-only-keyring.ts @@ -198,6 +198,7 @@ export class WatchOnlyKeyring implements Keyring { const checksumAddress = getChecksumAddress(hexAddress); const resolvedAccountType = accountType ?? EthAccountType.Eoa; + const resolvedScopes = this.#resolveScopes(scopes); if (!isEvmAccountType(resolvedAccountType)) { throw new Error( @@ -218,7 +219,7 @@ export class WatchOnlyKeyring implements Keyring { id, type: resolvedAccountType, address: checksumAddress, - scopes: this.#resolveScopes(scopes), + scopes: resolvedScopes, methods: [], options: {}, }; From f1e0bc29979f57c50dd40b087f08b9e5e6820b08 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 1 Oct 2026 13:34:22 +0200 Subject: [PATCH 11/13] fix: prevent account re-creation with different scopes/type --- .../src/watch-only-keyring.test.ts | 32 ++++++++++++++++++- .../src/watch-only-keyring.ts | 18 +++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/keyring-watch-only/src/watch-only-keyring.test.ts b/packages/keyring-watch-only/src/watch-only-keyring.test.ts index b635504ce..aa96d0752 100644 --- a/packages/keyring-watch-only/src/watch-only-keyring.test.ts +++ b/packages/keyring-watch-only/src/watch-only-keyring.test.ts @@ -184,10 +184,12 @@ describe('WatchOnlyKeyring', () => { ]); }); - it('returns the existing account when re-importing with different scopes', async () => { + 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, @@ -197,6 +199,34 @@ describe('WatchOnlyKeyring', () => { 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 a different type.'); + }); + + 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 incompatible scopes.'); + }); + it('generates deterministic account IDs', async () => { const [account] = await keyring.createAccounts( createAddressImportOptions(TEST_ADDRESS_1), diff --git a/packages/keyring-watch-only/src/watch-only-keyring.ts b/packages/keyring-watch-only/src/watch-only-keyring.ts index b5173d127..e8bc93ec4 100644 --- a/packages/keyring-watch-only/src/watch-only-keyring.ts +++ b/packages/keyring-watch-only/src/watch-only-keyring.ts @@ -212,6 +212,24 @@ export class WatchOnlyKeyring implements Keyring { const existingAccount = this.#registry.get(id); if (existingAccount) { + if (existingAccount.type !== resolvedAccountType) { + throw new Error( + 'Account already exists with a different 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 = resolvedScopes.some( + (scope) => !isScopeEqualToAny(scope, existingAccount.scopes), + ); + if (hasIncompatibleScopes) { + throw new Error( + 'Account already exists with incompatible scopes.', + ); + } + return existingAccount; } From 065493565443deffb1c20bd6deadd0655a7a70a1 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 1 Oct 2026 13:52:56 +0200 Subject: [PATCH 12/13] fix: fill registry atomically in deserialize --- .../src/watch-only-keyring.test.ts | 24 ++++++ .../src/watch-only-keyring.ts | 83 +++++++++++-------- 2 files changed, 74 insertions(+), 33 deletions(-) diff --git a/packages/keyring-watch-only/src/watch-only-keyring.test.ts b/packages/keyring-watch-only/src/watch-only-keyring.test.ts index aa96d0752..7b6413867 100644 --- a/packages/keyring-watch-only/src/watch-only-keyring.test.ts +++ b/packages/keyring-watch-only/src/watch-only-keyring.test.ts @@ -558,6 +558,30 @@ describe('WatchOnlyKeyring', () => { ); }); + 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)); diff --git a/packages/keyring-watch-only/src/watch-only-keyring.ts b/packages/keyring-watch-only/src/watch-only-keyring.ts index e8bc93ec4..db750eaa4 100644 --- a/packages/keyring-watch-only/src/watch-only-keyring.ts +++ b/packages/keyring-watch-only/src/watch-only-keyring.ts @@ -171,20 +171,19 @@ export class WatchOnlyKeyring implements Keyring { } /** - * Get or create the account for the given address. - * - * The address is validated as an EVM address and normalized to its EIP-55 - * checksum representation. Creating an account for an already-held address - * returns the existing account (idempotency). + * 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 The account for the given address. + * @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. */ - #getOrCreateAccount( + #toAccount( address: string, accountType: KeyringAccountType | undefined, scopes: readonly CaipChainId[] | undefined, @@ -196,7 +195,6 @@ export class WatchOnlyKeyring implements Keyring { } const checksumAddress = getChecksumAddress(hexAddress); - const resolvedAccountType = accountType ?? EthAccountType.Eoa; const resolvedScopes = this.#resolveScopes(scopes); @@ -206,44 +204,58 @@ export class WatchOnlyKeyring implements Keyring { ); } - // Registering an already-held address is idempotent: `register` returns - // the existing account ID. - const id = this.#registry.register(checksumAddress); + return { + // NOTE: The registry also uses this function to generate ID here. + id: generateEthAccountId(checksumAddress), + type: resolvedAccountType, + address: checksumAddress, + scopes: resolvedScopes, + methods: [], + options: {}, + }; + } - const existingAccount = this.#registry.get(id); + /** + * 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 !== resolvedAccountType) { - throw new Error( - 'Account already exists with a different type.', - ); + if (existingAccount.type !== account.type) { + throw new Error('Account already exists with a different 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 = resolvedScopes.some( + const hasIncompatibleScopes = account.scopes.some( (scope) => !isScopeEqualToAny(scope, existingAccount.scopes), ); if (hasIncompatibleScopes) { - throw new Error( - 'Account already exists with incompatible scopes.', - ); + throw new Error('Account already exists with incompatible scopes.'); } return existingAccount; } - const account: KeyringAccount = { - id, - type: resolvedAccountType, - address: checksumAddress, - scopes: resolvedScopes, - methods: [], - options: {}, - }; - this.#registry.set(account); - return account; } @@ -351,10 +363,15 @@ export class WatchOnlyKeyring implements Keyring { return this.#withLock(async () => { assert(state, WatchOnlyKeyringStateStruct); - this.#registry.clear(); + // 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), + ); - for (const { type, address, scopes } of state.accounts) { - this.#getOrCreateAccount(address, type, scopes); + this.#registry.clear(); + for (const account of accounts) { + this.#registry.set(account); } }); } From 72ae73d7acc5d058803fcb616ae1cf26afb990c7 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 1 Oct 2026 14:24:00 +0200 Subject: [PATCH 13/13] refactor: better error messages --- .../keyring-watch-only/src/watch-only-keyring.test.ts | 8 ++++++-- packages/keyring-watch-only/src/watch-only-keyring.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/keyring-watch-only/src/watch-only-keyring.test.ts b/packages/keyring-watch-only/src/watch-only-keyring.test.ts index 7b6413867..cabd4e0a2 100644 --- a/packages/keyring-watch-only/src/watch-only-keyring.test.ts +++ b/packages/keyring-watch-only/src/watch-only-keyring.test.ts @@ -208,7 +208,9 @@ describe('WatchOnlyKeyring', () => { keyring.createAccounts( createAddressImportOptions(TEST_ADDRESS_1, EthAccountType.Erc4337), ), - ).rejects.toThrow('Account already exists with a different type.'); + ).rejects.toThrow( + `Account already exists with type '${EthAccountType.Eoa}', got '${EthAccountType.Erc4337}'.`, + ); }); it('throws when re-importing an address with incompatible scopes', async () => { @@ -224,7 +226,9 @@ describe('WatchOnlyKeyring', () => { EthScope.Testnet, ]), ), - ).rejects.toThrow('Account already exists with incompatible scopes.'); + ).rejects.toThrow( + `Account already exists with scopes [${EthScope.Mainnet}], got [${EthScope.Testnet}].`, + ); }); it('generates deterministic account IDs', async () => { diff --git a/packages/keyring-watch-only/src/watch-only-keyring.ts b/packages/keyring-watch-only/src/watch-only-keyring.ts index db750eaa4..e78f80efa 100644 --- a/packages/keyring-watch-only/src/watch-only-keyring.ts +++ b/packages/keyring-watch-only/src/watch-only-keyring.ts @@ -239,7 +239,9 @@ export class WatchOnlyKeyring implements Keyring { const existingAccount = this.#registry.get(account.id); if (existingAccount) { if (existingAccount.type !== account.type) { - throw new Error('Account already exists with a different 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. @@ -249,7 +251,9 @@ export class WatchOnlyKeyring implements Keyring { (scope) => !isScopeEqualToAny(scope, existingAccount.scopes), ); if (hasIncompatibleScopes) { - throw new Error('Account already exists with incompatible scopes.'); + throw new Error( + `Account already exists with scopes [${existingAccount.scopes.join(', ')}], got [${account.scopes.join(', ')}].`, + ); } return existingAccount;