From d0db324b6d8a728c5afa6578a39ef93d987d6bd9 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 3 Jul 2025 18:08:27 +0200 Subject: [PATCH 01/27] feat: add multichain-account-controller implementation --- README.md | 2 + package.json | 3 +- .../CHANGELOG.md | 10 + .../multichain-account-controller/LICENSE | 20 + .../multichain-account-controller/README.md | 15 + .../jest.config.js | 26 + .../package.json | 92 ++++ .../src/MultichainAccountController.test.ts | 471 ++++++++++++++++++ .../src/MultichainAccountController.ts | 137 +++++ .../src/index.ts | 2 + .../src/providers/BaseAccountProvider.test.ts | 35 ++ .../src/providers/BaseAccountProvider.ts | 137 +++++ .../src/providers/EvmAccountProvider.test.ts | 249 +++++++++ .../src/providers/EvmAccountProvider.ts | 83 +++ .../src/providers/SolAccountProvider.test.ts | 269 ++++++++++ .../src/providers/SolAccountProvider.ts | 121 +++++ .../src/tests/accounts.ts | 181 +++++++ .../src/tests/index.ts | 2 + .../src/tests/messenger.ts | 44 ++ .../src/types.ts | 52 ++ .../tsconfig.build.json | 13 + .../tsconfig.json | 11 + .../typedoc.json | 7 + tsconfig.build.json | 1 + tsconfig.json | 1 + yarn.lock | 52 +- 26 files changed, 2033 insertions(+), 3 deletions(-) create mode 100644 packages/multichain-account-controller/CHANGELOG.md create mode 100644 packages/multichain-account-controller/LICENSE create mode 100644 packages/multichain-account-controller/README.md create mode 100644 packages/multichain-account-controller/jest.config.js create mode 100644 packages/multichain-account-controller/package.json create mode 100644 packages/multichain-account-controller/src/MultichainAccountController.test.ts create mode 100644 packages/multichain-account-controller/src/MultichainAccountController.ts create mode 100644 packages/multichain-account-controller/src/index.ts create mode 100644 packages/multichain-account-controller/src/providers/BaseAccountProvider.test.ts create mode 100644 packages/multichain-account-controller/src/providers/BaseAccountProvider.ts create mode 100644 packages/multichain-account-controller/src/providers/EvmAccountProvider.test.ts create mode 100644 packages/multichain-account-controller/src/providers/EvmAccountProvider.ts create mode 100644 packages/multichain-account-controller/src/providers/SolAccountProvider.test.ts create mode 100644 packages/multichain-account-controller/src/providers/SolAccountProvider.ts create mode 100644 packages/multichain-account-controller/src/tests/accounts.ts create mode 100644 packages/multichain-account-controller/src/tests/index.ts create mode 100644 packages/multichain-account-controller/src/tests/messenger.ts create mode 100644 packages/multichain-account-controller/src/types.ts create mode 100644 packages/multichain-account-controller/tsconfig.build.json create mode 100644 packages/multichain-account-controller/tsconfig.json create mode 100644 packages/multichain-account-controller/typedoc.json diff --git a/README.md b/README.md index 102537d133a..43c33a5a1d0 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/logging-controller`](packages/logging-controller) - [`@metamask/message-manager`](packages/message-manager) - [`@metamask/messenger`](packages/messenger) +- [`@metamask/multichain-account-controller`](packages/multichain-account-controller) - [`@metamask/multichain-api-middleware`](packages/multichain-api-middleware) - [`@metamask/multichain-network-controller`](packages/multichain-network-controller) - [`@metamask/multichain-transactions-controller`](packages/multichain-transactions-controller) @@ -106,6 +107,7 @@ linkStyle default opacity:0.5 logging_controller(["@metamask/logging-controller"]); message_manager(["@metamask/message-manager"]); messenger(["@metamask/messenger"]); + multichain_account_controller(["@metamask/multichain-account-controller"]); multichain_api_middleware(["@metamask/multichain-api-middleware"]); multichain_network_controller(["@metamask/multichain-network-controller"]); multichain_transactions_controller(["@metamask/multichain-transactions-controller"]); diff --git a/package.json b/package.json index 4c367f34736..3311358e70f 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "resolutions": { "elliptic@6.5.4": "^6.5.7", "fast-xml-parser@^4.3.4": "^4.4.1", - "ws@7.4.6": "^7.5.10" + "ws@7.4.6": "^7.5.10", + "@metamask/multichain-account-api@^0.0.0": "npm:@metamask-previews/multichain-account-api@0.0.0-5ab4699" }, "devDependencies": { "@babel/core": "^7.23.5", diff --git a/packages/multichain-account-controller/CHANGELOG.md b/packages/multichain-account-controller/CHANGELOG.md new file mode 100644 index 00000000000..b518709c7b8 --- /dev/null +++ b/packages/multichain-account-controller/CHANGELOG.md @@ -0,0 +1,10 @@ +# 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] + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/multichain-account-controller/LICENSE b/packages/multichain-account-controller/LICENSE new file mode 100644 index 00000000000..7d002dced3a --- /dev/null +++ b/packages/multichain-account-controller/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/multichain-account-controller/README.md b/packages/multichain-account-controller/README.md new file mode 100644 index 00000000000..12ba493251d --- /dev/null +++ b/packages/multichain-account-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/multichain-account-controller` + +example + +## Installation + +`yarn add @metamask/multichain-account-controller` + +or + +`npm install @metamask/multichain-account-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/multichain-account-controller/jest.config.js b/packages/multichain-account-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/multichain-account-controller/jest.config.js @@ -0,0 +1,26 @@ +/* + * 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, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/multichain-account-controller/package.json b/packages/multichain-account-controller/package.json new file mode 100644 index 00000000000..1947241635c --- /dev/null +++ b/packages/multichain-account-controller/package.json @@ -0,0 +1,92 @@ +{ + "name": "@metamask/multichain-account-controller", + "version": "0.0.0", + "description": "Controller to manage multichain accounts", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/multichain-account-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "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" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/multichain-account-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/multichain-account-controller", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/base-controller": "^8.0.1", + "@metamask/keyring-api": "^18.0.0", + "@metamask/keyring-controller": "^19.0.0", + "@metamask/keyring-internal-api": "^6.2.0", + "@metamask/keyring-snap-client": "^5.0.0", + "@metamask/multichain-account-api": "^0.0.0", + "@metamask/snaps-sdk": "^7.1.0", + "@metamask/snaps-utils": "^9.4.0" + }, + "devDependencies": { + "@metamask/accounts-controller": "^31.0.0", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/eth-snap-keyring": "^13.0.0", + "@metamask/keyring-controller": "^22.0.2", + "@metamask/providers": "^22.1.0", + "@metamask/snaps-controllers": "^12.3.1", + "@types/jest": "^27.4.1", + "@types/uuid": "^8.3.0", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.2.2", + "uuid": "^8.3.2", + "webextension-polyfill": "^0.12.0" + }, + "peerDependencies": { + "@metamask/accounts-controller": "^31.0.0", + "@metamask/keyring-controller": "^22.0.0", + "@metamask/multichain-account-api": "^0.0.0", + "@metamask/providers": "^22.0.0", + "@metamask/snaps-controllers": "^12.0.0", + "webextension-polyfill": "^0.10.0 || ^0.11.0 || ^0.12.0" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/multichain-account-controller/src/MultichainAccountController.test.ts b/packages/multichain-account-controller/src/MultichainAccountController.test.ts new file mode 100644 index 00000000000..7dd537cc2b3 --- /dev/null +++ b/packages/multichain-account-controller/src/MultichainAccountController.test.ts @@ -0,0 +1,471 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import type { Messenger } from '@metamask/base-controller'; +import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; +import { EthAccountType, SolAccountType } from '@metamask/keyring-api'; +import type { KeyringObject } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +import { MultichainAccountController } from './MultichainAccountController'; +import { EvmAccountProvider } from './providers/EvmAccountProvider'; +import { SolAccountProvider } from './providers/SolAccountProvider'; +import { + getMultichainAccountControllerMessenger, + getRootMessenger, + MOCK_HARDWARE_ACCOUNT_1, + MOCK_HD_ACCOUNT_1, + MOCK_HD_ACCOUNT_2, + MOCK_HD_KEYRING_1, + MOCK_HD_KEYRING_2, + MOCK_SNAP_ACCOUNT_1, + MOCK_SNAP_ACCOUNT_2, + MockAccountBuilder, +} from './tests'; +import type { + AllowedActions, + AllowedEvents, + MultichainAccountControllerActions, + MultichainAccountControllerEvents, + MultichainAccountControllerMessenger, +} from './types'; + +// Mock providers. +jest.mock('./providers/EvmAccountProvider', () => { + return { + ...jest.requireActual('./providers/EvmAccountProvider'), + EvmAccountProvider: jest.fn(), + }; +}); +jest.mock('./providers/SolAccountProvider', () => { + return { + ...jest.requireActual('./providers/SolAccountProvider'), + SolAccountProvider: jest.fn(), + }; +}); + +type MockAccountProvider = { + getAccount: jest.Mock; + getAccounts: jest.Mock; + createAccounts: jest.Mock; + discoverAndCreateAccounts: jest.Mock; +}; +type Mocks = { + listMultichainAccounts: jest.Mock; + EvmAccountProvider: MockAccountProvider; + SolAccountProvider: MockAccountProvider; +}; + +function mockAccountProvider( + providerClass: new ( + messenger: MultichainAccountControllerMessenger, + ) => Provider, + mocks: MockAccountProvider, + accounts: InternalAccount[], + type: KeyringAccount['type'], +) { + jest + .mocked(providerClass) + .mockImplementation(() => mocks as unknown as Provider); + + mocks.getAccounts.mockImplementation( + ({ + entropySource, + groupIndex, + }: { + entropySource: EntropySourceId; + groupIndex: number; + }) => + accounts + .filter( + (account) => + account.type === type && + account.options.entropySource === entropySource && + account.options.index === groupIndex, + ) + .map((account) => account.id), + ); + + mocks.getAccount.mockImplementation((id: InternalAccount['id']) => { + return accounts.find((account) => account.id === id); + }); +} + +function setup({ + messenger = getRootMessenger(), + keyrings = [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + accounts, +}: { + messenger?: Messenger< + MultichainAccountControllerActions | AllowedActions, + MultichainAccountControllerEvents | AllowedEvents + >; + keyrings?: KeyringObject[]; + accounts?: InternalAccount[]; +} = {}): { + controller: MultichainAccountController; + messenger: Messenger< + MultichainAccountControllerActions | AllowedActions, + MultichainAccountControllerEvents | AllowedEvents + >; + mocks: Mocks; +} { + const mocks: Mocks = { + listMultichainAccounts: jest.fn(), + EvmAccountProvider: { + getAccount: jest.fn(), + getAccounts: jest.fn(), + createAccounts: jest.fn(), + discoverAndCreateAccounts: jest.fn(), + }, + SolAccountProvider: { + getAccount: jest.fn(), + getAccounts: jest.fn(), + createAccounts: jest.fn(), + discoverAndCreateAccounts: jest.fn(), + }, + }; + + messenger.registerActionHandler('KeyringController:getState', () => ({ + isUnlocked: true, + keyrings, + })); + + if (accounts) { + mocks.listMultichainAccounts.mockImplementation(() => accounts); + + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + mocks.listMultichainAccounts, + ); + + mockAccountProvider( + EvmAccountProvider, + mocks.EvmAccountProvider, + accounts, + EthAccountType.Eoa, + ); + mockAccountProvider( + SolAccountProvider, + mocks.SolAccountProvider, + accounts, + SolAccountType.DataAccount, + ); + } + + const controller = new MultichainAccountController({ + messenger: getMultichainAccountControllerMessenger(messenger), + }); + controller.init(); + + return { controller, messenger, mocks }; +} + +describe('MultichainAccountController', () => { + describe('getMultichainAccounts', () => { + it('gets multichain accounts', () => { + const { controller } = setup({ + accounts: [ + // Wallet 1: + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(), + MockAccountBuilder.from(MOCK_SNAP_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(), + // Wallet 2: + MockAccountBuilder.from(MOCK_HD_ACCOUNT_2) + .withEntropySource(MOCK_HD_KEYRING_2.metadata.id) + .withGroupIndex(0) + .get(), + // Not HD accounts + MOCK_SNAP_ACCOUNT_2, + MOCK_HARDWARE_ACCOUNT_1, + ], + }); + + expect( + controller.getMultichainAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }), + ).toHaveLength(1); + expect( + controller.getMultichainAccounts({ + entropySource: MOCK_HD_KEYRING_2.metadata.id, + }), + ).toHaveLength(1); + }); + + it('gets multichain accounts with multiple wallets', () => { + const { controller } = setup({ + accounts: [ + // Wallet 1: + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(), + MockAccountBuilder.from(MOCK_SNAP_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(), + ], + }); + + const multichainAccounts = controller.getMultichainAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }); + expect(multichainAccounts).toHaveLength(2); // Group index 0 + 1. + + const internalAccounts0 = multichainAccounts[0].getAccounts(); + expect(internalAccounts0).toHaveLength(1); // Just EVM. + expect(internalAccounts0[0].type).toBe(EthAccountType.Eoa); + + const internalAccounts1 = multichainAccounts[1].getAccounts(); + expect(internalAccounts1).toHaveLength(1); // Just SOL. + expect(internalAccounts1[0].type).toBe(SolAccountType.DataAccount); + }); + + it('throws if trying to access an unknown wallet', () => { + const { controller } = setup({ + keyrings: [MOCK_HD_KEYRING_1], + accounts: [ + // Wallet 1: + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(), + ], + }); + + // Wallet 2 should not exist, thus, this should throw. + expect(() => + // NOTE: We use `getMultichainAccounts` which uses `#getWallet` under the hood. + controller.getMultichainAccounts({ + entropySource: MOCK_HD_KEYRING_2.metadata.id, + }), + ).toThrow('Unknown wallet, not wallet matching this entropy source'); + }); + }); + + describe('getMultichainAccount', () => { + it('gets a specific multichain account', () => { + const accounts = [ + // Wallet 1: + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(), + MockAccountBuilder.from(MOCK_HD_ACCOUNT_2) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(), + ]; + const { controller } = setup({ + accounts, + }); + + const groupIndex = 1; + const multichainAccount = controller.getMultichainAccount({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex, + }); + expect(multichainAccount.index).toBe(groupIndex); + + const internalAccounts = multichainAccount.getAccounts(); + expect(internalAccounts).toHaveLength(1); + expect(internalAccounts[0]).toStrictEqual(accounts[1]); + }); + + it('throws if trying to access an out-of-bound group index', () => { + const { controller } = setup({ + accounts: [ + // Wallet 1: + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(), + ], + }); + + const groupIndex = 1; + expect(() => + controller.getMultichainAccount({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex, + }), + ).toThrow(`No multichain account for index: ${groupIndex}`); + }); + }); + + describe('createNextMultichainAccount', () => { + it('creates the next multichain account', async () => { + // Used to build the initial wallet with 1 multichain account (for + // group index 0)! + const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + + const { controller, mocks } = setup({ accounts: [mockEvmAccount] }); + + // Before creating the next multichain account, we need to mock some actions: + const mockNextEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_2) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(); + const mockNextSolAccount = MockAccountBuilder.from(MOCK_SNAP_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .withUuuid() // Required by KeyringClient. + .get(); + + // We need to mock every call made to the providers when creating an accounts: + for (const [mocksAccountProvider, mockNextAccount] of [ + [mocks.EvmAccountProvider, mockNextEvmAccount], + [mocks.SolAccountProvider, mockNextSolAccount], + ] as const) { + // 1. Create the accounts for the new index and returns their IDs. + mocksAccountProvider.createAccounts.mockResolvedValueOnce([ + mockNextAccount.id, + ]); + // 2. When the adapter creates a new multichain account, it will query all + // accounts for this given index (so similar to the one we just created). + mocksAccountProvider.getAccounts.mockReturnValueOnce([mockNextAccount]); + // 3. Required when we call `getAccounts` (below) on the multichain account. + mocksAccountProvider.getAccount.mockReturnValueOnce(mockNextAccount); + } + + const multichainAccount = await controller.createNextMultichainAccount({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }); + expect(multichainAccount.index).toBe(1); + + const internalAccounts = multichainAccount.getAccounts(); + expect(internalAccounts).toHaveLength(2); // EVM + SOL. + expect(internalAccounts[0].type).toBe(EthAccountType.Eoa); + expect(internalAccounts[1].type).toBe(SolAccountType.DataAccount); + }); + }); + + describe('discoverAndCreateMultichainAccounts', () => { + it('discovers and creates multichain accounts', async () => { + // Starts with no accounts, to simulate the discovery. + const { controller, mocks } = setup({ accounts: [] }); + + // We need to mock every call made to the providers when discovery an accounts: + for (const [mocksAccountProvider, mockDiscoveredAccount] of [ + [mocks.EvmAccountProvider, MOCK_HD_ACCOUNT_1], + [mocks.SolAccountProvider, MOCK_SNAP_ACCOUNT_1], + ] as const) { + mocksAccountProvider.discoverAndCreateAccounts.mockResolvedValueOnce([ + mockDiscoveredAccount.id, // Account that got discovered and created. + ]); + mocksAccountProvider.discoverAndCreateAccounts.mockResolvedValueOnce( + [], // Stop the discovery. + ); + mocksAccountProvider.getAccounts.mockReturnValue([ + mockDiscoveredAccount.id, // Account that got created during discovery. + ]); + mocksAccountProvider.getAccount.mockReturnValue(mockDiscoveredAccount); + } + + const multichainAccounts = + await controller.discoverAndCreateMultichainAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }); + // We only discover 1 account on each providers, which should only have 1 multichain + // account. + expect(multichainAccounts).toHaveLength(1); + expect(multichainAccounts[0].index).toBe(0); + + const internalAccounts = multichainAccounts[0].getAccounts(); + expect(internalAccounts).toHaveLength(2); // EVM + SOL. + expect(internalAccounts[0].type).toBe(EthAccountType.Eoa); + expect(internalAccounts[1].type).toBe(SolAccountType.DataAccount); + }); + + it('discovers and creates multichain accounts for multiple index', async () => { + // Starts with no accounts, to simulate the discovery. + const { controller, mocks } = setup({ accounts: [] }); + + const maxGroupIndex = 10; + for (let i = 0; i < maxGroupIndex; i++) { + // We need to mock every call made to the providers when discovery an accounts: + for (const [mocksAccountProvider, mockDiscoveredAccount] of [ + [mocks.EvmAccountProvider, MOCK_HD_ACCOUNT_1], + [mocks.SolAccountProvider, MOCK_SNAP_ACCOUNT_1], + ] as const) { + const mockDiscoveredAccountForIndex = MockAccountBuilder.from( + mockDiscoveredAccount, + ) + .withGroupIndex(i) + .withUuuid() + .get(); + + mocksAccountProvider.discoverAndCreateAccounts.mockResolvedValueOnce([ + mockDiscoveredAccountForIndex.id, // Account that got discovered and created. + ]); + } + } + + // Stop the discoveries. + mocks.EvmAccountProvider.discoverAndCreateAccounts.mockResolvedValueOnce( + [], + ); + mocks.SolAccountProvider.discoverAndCreateAccounts.mockResolvedValueOnce( + [], + ); + + const multichainAccounts = + await controller.discoverAndCreateMultichainAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }); + expect(multichainAccounts).toHaveLength(maxGroupIndex); + }); + + it('discovers and creates multichain accounts and fill gaps (alignmnent mechanism)', async () => { + // Starts with no accounts, to simulate the discovery. + const { controller, mocks } = setup({ accounts: [] }); + + // We only mock calls for the EVM providers, the Solana provider won't discovery anything. + const mocksAccountProvider = mocks.EvmAccountProvider; + const mockDiscoveredAccount = MOCK_HD_ACCOUNT_1; + mocksAccountProvider.discoverAndCreateAccounts.mockResolvedValueOnce([ + mockDiscoveredAccount.id, // Account that got discovered and created. + ]); + mocksAccountProvider.discoverAndCreateAccounts.mockResolvedValueOnce( + [], // Stop the discovery. + ); + mocksAccountProvider.getAccounts.mockReturnValue([ + mockDiscoveredAccount.id, // Account that got created during discovery. + ]); + mocksAccountProvider.getAccount.mockReturnValue(mockDiscoveredAccount); + + // No discovery for Solana. + mocks.SolAccountProvider.discoverAndCreateAccounts.mockResolvedValue([]); + mocks.SolAccountProvider.createAccounts.mockResolvedValue( + MOCK_SNAP_ACCOUNT_1.id, + ); + mocks.SolAccountProvider.getAccounts.mockReturnValue([ + MOCK_SNAP_ACCOUNT_1.id, + ]); + mocks.SolAccountProvider.getAccount.mockReturnValue(MOCK_SNAP_ACCOUNT_1); + + const multichainAccounts = + await controller.discoverAndCreateMultichainAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }); + // We only discover 1 account on the EVM providers, which is still produce 1 multichain + // account. + expect(multichainAccounts).toHaveLength(1); + expect(multichainAccounts[0].index).toBe(0); + + // And Solana account must have been created too (we "aligned" all accounts). + expect(mocks.SolAccountProvider.createAccounts).toHaveBeenCalled(); + const internalAccounts = multichainAccounts[0].getAccounts(); + expect(internalAccounts).toHaveLength(2); // EVM + SOL. + expect(internalAccounts[0].type).toBe(EthAccountType.Eoa); + expect(internalAccounts[1].type).toBe(SolAccountType.DataAccount); + }); + }); +}); diff --git a/packages/multichain-account-controller/src/MultichainAccountController.ts b/packages/multichain-account-controller/src/MultichainAccountController.ts new file mode 100644 index 00000000000..a5e2fbeecef --- /dev/null +++ b/packages/multichain-account-controller/src/MultichainAccountController.ts @@ -0,0 +1,137 @@ +import type { EntropySourceId } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { + AccountProvider, + MultichainAccountWalletId, +} from '@metamask/multichain-account-api'; +import { + MultichainAccountWalletAdapter, + toMultichainAccountWalletId, + type MultichainAccount, + type MultichainAccountWallet, +} from '@metamask/multichain-account-api'; + +import { EvmAccountProvider } from './providers/EvmAccountProvider'; +import { SolAccountProvider } from './providers/SolAccountProvider'; +import type { MultichainAccountControllerMessenger } from './types'; + +/** + * The options that {@link MultichainAccountController} takes. + */ +type MultichainAccountControllerOptions = { + messenger: MultichainAccountControllerMessenger; +}; + +/** + * Stateless controller to expose multichain accounts capabilities. + */ +export class MultichainAccountController { + readonly #messenger: MultichainAccountControllerMessenger; + + readonly #providers: AccountProvider[]; + + readonly #wallets: Map< + MultichainAccountWalletId, + MultichainAccountWallet + >; + + /** + * Constructs a new MultichainAccountController. + * + * @param options - The options. + * @param options.messenger - The messenger suited to this + * MultichainAccountController. + */ + constructor({ messenger }: MultichainAccountControllerOptions) { + this.#messenger = messenger; + this.#wallets = new Map(); + // TODO: Rely on keyring capabilities once the keyring API is used by all keyrings. + this.#providers = [ + new EvmAccountProvider(this.#messenger), + new SolAccountProvider(this.#messenger), + ]; + } + + init(): void { + // Gather all entropy sources first. + const { keyrings } = this.#messenger.call('KeyringController:getState'); + + const entropySources = []; + for (const keyring of keyrings) { + if (keyring.type === KeyringTypes.hd) { + entropySources.push(keyring.metadata.id); + } + } + + for (const entropySource of entropySources) { + // This will automatically create all multichain accounts for that wallet (based + // on the accounts owned by each account providers). + const wallet = new MultichainAccountWalletAdapter({ + entropySource, + providers: this.#providers, + }); + + this.#wallets.set(wallet.id, wallet); + } + } + + #getWallet( + entropySource: EntropySourceId, + ): MultichainAccountWallet { + const wallet = this.#wallets.get( + toMultichainAccountWalletId(entropySource), + ); + + if (!wallet) { + throw new Error( + 'Unknown wallet, not wallet matching this entropy source', + ); + } + + return wallet; + } + + getMultichainAccount({ + entropySource, + groupIndex, + }: { + entropySource: EntropySourceId; + groupIndex: number; + }): MultichainAccount { + const multichainAccount = + this.#getWallet(entropySource).getMultichainAccount(groupIndex); + + if (!multichainAccount) { + throw new Error(`No multichain account for index: ${groupIndex}`); + } + + return multichainAccount; + } + + getMultichainAccounts({ + entropySource, + }: { + entropySource: EntropySourceId; + }): MultichainAccount[] { + return this.#getWallet(entropySource).getMultichainAccounts(); + } + + async createNextMultichainAccount({ + entropySource, + }: { + entropySource: EntropySourceId; + }): Promise> { + return await this.#getWallet(entropySource).createNextMultichainAccount(); + } + + async discoverAndCreateMultichainAccounts({ + entropySource, + }: { + entropySource: EntropySourceId; + }): Promise[]> { + return await this.#getWallet( + entropySource, + ).discoverAndCreateMultichainAccounts(); + } +} diff --git a/packages/multichain-account-controller/src/index.ts b/packages/multichain-account-controller/src/index.ts new file mode 100644 index 00000000000..a73c695eba3 --- /dev/null +++ b/packages/multichain-account-controller/src/index.ts @@ -0,0 +1,2 @@ +export type { MultichainAccountControllerMessenger } from './types'; +export { MultichainAccountController } from './MultichainAccountController'; diff --git a/packages/multichain-account-controller/src/providers/BaseAccountProvider.test.ts b/packages/multichain-account-controller/src/providers/BaseAccountProvider.test.ts new file mode 100644 index 00000000000..4277439b244 --- /dev/null +++ b/packages/multichain-account-controller/src/providers/BaseAccountProvider.test.ts @@ -0,0 +1,35 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Json } from '@metamask/utils'; + +import { isBip44Account } from './BaseAccountProvider'; +import { MOCK_HD_ACCOUNT_1 } from '../tests'; + +describe('isBip44Account', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns true if an account is BIP-44 compatible', () => { + expect(isBip44Account(MOCK_HD_ACCOUNT_1)).toBe(true); + }); + + it.each([ + { tc: 'no entropy', options: { entropySource: undefined } }, + { tc: 'no index', options: { index: undefined } }, + ])( + 'returns false if an account is not BIP-44 compatible: $tc', + ({ options }) => { + const account: InternalAccount = { + ...MOCK_HD_ACCOUNT_1, + options: { + ...MOCK_HD_ACCOUNT_1.options, + ...options, + } as unknown as Record, // To allow `undefined` values. + }; + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + expect(isBip44Account(account)).toBe(false); + expect(consoleSpy).toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/multichain-account-controller/src/providers/BaseAccountProvider.ts b/packages/multichain-account-controller/src/providers/BaseAccountProvider.ts new file mode 100644 index 00000000000..980af797b2a --- /dev/null +++ b/packages/multichain-account-controller/src/providers/BaseAccountProvider.ts @@ -0,0 +1,137 @@ +import type { AccountId } from '@metamask/accounts-controller'; +import type { KeyringAccount } from '@metamask/keyring-api'; +import { type EntropySourceId } from '@metamask/keyring-api'; +import { + type KeyringMetadata, + type KeyringSelector, +} from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { AccountProvider } from '@metamask/multichain-account-api'; + +import type { MultichainAccountControllerMessenger } from '../types'; + +export type Bip44Account = Account & { + options: { + index: number; + entropySource: EntropySourceId; + }; +}; + +/** + * Checks if an account is BIP-44 compatible. + * + * @param account - The account to be tested. + * @returns True if the account is BIP-44 compatible. + */ +export function isBip44Account( + account: Account, +): account is Bip44Account { + // TODO: Maybe use superstruct to validate the structure of HD account since they are not strongly-typed for now? + if (!account.options.entropySource) { + console.warn( + "! Found an HD account with no entropy source: account won't be associated to its wallet.", + ); + return false; + } + + // TODO: We need to add this index for native accounts too! + if (account.options.index === undefined) { + console.warn( + "! Found an HD account with no index: account won't be associated to its wallet.", + ); + return false; + } + + return true; +} + +export abstract class BaseAccountProvider + implements AccountProvider +{ + protected readonly messenger: MultichainAccountControllerMessenger; + + constructor(messenger: MultichainAccountControllerMessenger) { + this.messenger = messenger; + } + + protected async withKeyring( + selector: KeyringSelector, + operation: ({ + keyring, + metadata, + }: { + keyring: SelectedKeyring; + metadata: KeyringMetadata; + }) => Promise, + ): Promise { + const result = await this.messenger.call( + 'KeyringController:withKeyring', + selector, + ({ keyring, metadata }) => + operation({ + keyring: keyring as SelectedKeyring, + metadata, + }), + ); + + return result as CallbackResult; + } + + #getAccounts( + filter: (account: InternalAccount) => boolean, + ): Bip44Account[] { + const accounts: Bip44Account[] = []; + + for (const account of this.messenger.call( + 'AccountsController:listMultichainAccounts', + )) { + if ( + this.isAccountCompatible(account) && + isBip44Account(account) && + filter(account) + ) { + accounts.push(account); + } + } + + return accounts; + } + + getAccounts({ + entropySource, + groupIndex, + }: { + entropySource: EntropySourceId; + groupIndex: number; + }): AccountId[] { + return this.#getAccounts((account) => { + return ( + account.options.entropySource === entropySource && + account.options.index === groupIndex + ); + }).map((account) => account.id); + } + + getAccount(id: AccountId): InternalAccount { + // TODO: Maybe just use a proper find for faster lookup? + const [found] = this.#getAccounts((account) => account.id === id); + + if (!found) { + throw new Error(`Unable to find account: ${id}`); + } + + return found; + } + + abstract isAccountCompatible(account: InternalAccount): boolean; + + abstract createAccounts(opts: { + entropySource: EntropySourceId; + groupIndex: number; + }): Promise; + + abstract discoverAndCreateAccounts(opts: { + entropySource: EntropySourceId; + groupIndex: number; + }): Promise; +} diff --git a/packages/multichain-account-controller/src/providers/EvmAccountProvider.test.ts b/packages/multichain-account-controller/src/providers/EvmAccountProvider.test.ts new file mode 100644 index 00000000000..0afdb22519e --- /dev/null +++ b/packages/multichain-account-controller/src/providers/EvmAccountProvider.test.ts @@ -0,0 +1,249 @@ +import type { Messenger } from '@metamask/base-controller'; +import type { KeyringMetadata } from '@metamask/keyring-controller'; +import type { + EthKeyring, + InternalAccount, +} from '@metamask/keyring-internal-api'; + +import { EvmAccountProvider } from './EvmAccountProvider'; +import { + getMultichainAccountControllerMessenger, + getRootMessenger, + MOCK_HD_ACCOUNT_1, + MOCK_HD_ACCOUNT_2, + MOCK_HD_KEYRING_1, + MockAccountBuilder, +} from '../tests'; +import type { + AllowedActions, + AllowedEvents, + MultichainAccountControllerActions, + MultichainAccountControllerEvents, +} from '../types'; + +class MockEthKeyring implements EthKeyring { + readonly type = 'MockEthKeyring'; + + readonly metadata: KeyringMetadata = { + id: 'mock-eth-keyring-id', + name: '', + }; + + readonly accounts: InternalAccount[]; + + constructor(accounts: InternalAccount[]) { + this.accounts = accounts; + } + + async serialize() { + return 'serialized'; + } + + async deserialize(_: string) { + // Not required. + } + + getAccounts = jest + .fn() + .mockImplementation(() => this.accounts.map((account) => account.address)); + + addAccounts = jest.fn().mockImplementation((numberOfAccounts: number) => { + const newAccountsIndex = this.accounts.length; + + // Just generate a new address by appending the number of accounts owned by that fake keyring. + for (let i = 0; i < numberOfAccounts; i++) { + this.accounts.push( + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withUuuid() + .withAddressSuffix(`${this.accounts.length}`) + .get(), + ); + } + + return this.accounts + .slice(newAccountsIndex) + .map((account) => account.address); + }); +} + +/** + * Sets up a EvmAccountProvider for testing. + * + * @param options - Configuration options for setup. + * @param options.messenger - An optional messenger instance to use. Defaults to a new Messenger. + * @param options.accounts - List of accounts to use. + * @returns An object containing the controller instance and the messenger. + */ +function setup({ + messenger = getRootMessenger(), + accounts = [], +}: { + messenger?: Messenger< + MultichainAccountControllerActions | AllowedActions, + MultichainAccountControllerEvents | AllowedEvents + >; + accounts?: InternalAccount[]; +} = {}): { + provider: EvmAccountProvider; + messenger: Messenger< + MultichainAccountControllerActions | AllowedActions, + MultichainAccountControllerEvents | AllowedEvents + >; + keyring: MockEthKeyring; + mocks: { + getAccountsByAddress: jest.Mock; + }; +} { + const keyring = new MockEthKeyring(accounts); + + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => accounts, + ); + + const mockGetAccountByAddress = jest + .fn() + .mockImplementation((address: string) => + keyring.accounts.find((account) => account.address === address), + ); + messenger.registerActionHandler( + 'AccountsController:getAccountByAddress', + mockGetAccountByAddress, + ); + + messenger.registerActionHandler( + 'KeyringController:withKeyring', + async (_, operation) => operation({ keyring, metadata: keyring.metadata }), + ); + + const provider = new EvmAccountProvider( + getMultichainAccountControllerMessenger(messenger), + ); + + return { + provider, + messenger, + keyring, + mocks: { + getAccountsByAddress: mockGetAccountByAddress, + }, + }; +} + +describe('EvmAccountProvider', () => { + it('gets accounts', () => { + const { provider } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2], + }); + + const accountsForIndex0 = provider.getAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + const accountsForIndex1 = provider.getAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 1, + }); + expect(accountsForIndex0).toHaveLength(1); + expect(accountsForIndex1).toHaveLength(0); + }); + + it('gets a specific account', () => { + const account = MOCK_HD_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + + expect(provider.getAccount(account.id)).toStrictEqual(account); + }); + + it('throws if account does not exist', () => { + const account = MOCK_HD_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + + const unknownAccount = MOCK_HD_ACCOUNT_2; + expect(() => provider.getAccount(unknownAccount.id)).toThrow( + `Unable to find account: ${unknownAccount.id}`, + ); + }); + + it('creates accounts', async () => { + const accounts = [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2]; + const { provider, keyring } = setup({ + accounts, + }); + + const newGroupIndex = accounts.length; // Group-index are 0-based. + const newAccounts = await provider.createAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: newGroupIndex, + }); + expect(newAccounts).toHaveLength(1); + expect(keyring.getAccounts).toHaveBeenCalled(); // Checks for existing accounts. + expect(keyring.addAccounts).toHaveBeenCalledWith(1); // Create 1 account. + }); + + it('does not re-create accounts (idempotent)', async () => { + const accounts = [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2]; + const { provider } = setup({ + accounts, + }); + + const newAccounts = await provider.createAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + expect(newAccounts).toHaveLength(1); + expect(newAccounts[0]).toStrictEqual(MOCK_HD_ACCOUNT_1.id); + }); + + it('throws when trying to create gaps', async () => { + const { provider } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + }); + + await expect( + provider.createAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 10, + }), + ).rejects.toThrow('Trying to create too many accounts'); + }); + + it('throws if internal account cannot be found', async () => { + const { provider, mocks } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + }); + + // Simulate an account not found. + mocks.getAccountsByAddress.mockImplementation(() => undefined); + + await expect( + provider.createAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 1, + }), + ).rejects.toThrow('Internal account does not exist'); + }); + + it('discover accounts', async () => { + const { provider } = setup({ + accounts: [], // No accounts by defaults, so we can discover them + }); + + const accounts = await provider.discoverAndCreateAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + expect(accounts).toHaveLength(1); + + // For now, we cannot beyond index 0 for the discovery. + const noAccounts = await provider.discoverAndCreateAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 1, + }); + expect(noAccounts).toHaveLength(0); + }); +}); diff --git a/packages/multichain-account-controller/src/providers/EvmAccountProvider.ts b/packages/multichain-account-controller/src/providers/EvmAccountProvider.ts new file mode 100644 index 00000000000..23b9750cf64 --- /dev/null +++ b/packages/multichain-account-controller/src/providers/EvmAccountProvider.ts @@ -0,0 +1,83 @@ +import { EthAccountType, type EntropySourceId } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { + EthKeyring, + InternalAccount, +} from '@metamask/keyring-internal-api'; +import type { Hex } from '@metamask/utils'; + +import { BaseAccountProvider } from './BaseAccountProvider'; + +// Max index used by discovery (until we move the proper discovery here). +const MAX_GROUP_INDEX = 1; + +// eslint-disable-next-line jsdoc/require-jsdoc +function assertInternalAccountExists( + account: InternalAccount | undefined, +): asserts account is InternalAccount { + if (!account) { + throw new Error('Internal account does not exist'); + } +} + +export class EvmAccountProvider extends BaseAccountProvider { + isAccountCompatible(account: InternalAccount): boolean { + return ( + account.type === EthAccountType.Eoa && + account.metadata.keyring.type === (KeyringTypes.hd as string) + ); + } + + async createAccounts({ + entropySource, + groupIndex, + }: { + entropySource: EntropySourceId; + groupIndex: number; + }) { + const [address] = await this.withKeyring( + { id: entropySource }, + async ({ keyring }) => { + const accounts = await keyring.getAccounts(); + if (groupIndex < accounts.length) { + // Nothing new to create, we just re-use the existing accounts here, + return [accounts[groupIndex]]; + } + + // For now, we don't allow for gap, so if we need to create a new + // account, this has to be the next one. + if (groupIndex !== accounts.length) { + throw new Error('Trying to create too many accounts'); + } + + // Create next account (and returns their addresses). + return await keyring.addAccounts(1); + }, + ); + + const account = this.messenger.call( + 'AccountsController:getAccountByAddress', + address, + ); + + // We MUST have the associated internal account. + assertInternalAccountExists(account); + + return [account.id]; + } + + override async discoverAndCreateAccounts({ + entropySource, + groupIndex, + }: { + entropySource: EntropySourceId; + groupIndex: number; + }) { + // TODO: Move account discovery here (for EVM). + + if (groupIndex < MAX_GROUP_INDEX) { + return await this.createAccounts({ entropySource, groupIndex }); + } + return []; + } +} diff --git a/packages/multichain-account-controller/src/providers/SolAccountProvider.test.ts b/packages/multichain-account-controller/src/providers/SolAccountProvider.test.ts new file mode 100644 index 00000000000..214fb8b9cca --- /dev/null +++ b/packages/multichain-account-controller/src/providers/SolAccountProvider.test.ts @@ -0,0 +1,269 @@ +import type { Messenger } from '@metamask/base-controller'; +import type { SnapKeyring } from '@metamask/eth-snap-keyring'; +import type { DiscoveredAccount } from '@metamask/keyring-api'; +import { SolScope } from '@metamask/keyring-api'; +import type { KeyringMetadata } from '@metamask/keyring-controller'; +import type { + EthKeyring, + InternalAccount, +} from '@metamask/keyring-internal-api'; + +import { SolAccountProvider } from './SolAccountProvider'; +import { + getMultichainAccountControllerMessenger, + getRootMessenger, + MOCK_HD_ACCOUNT_1, + MOCK_HD_KEYRING_1, + MOCK_HD_KEYRING_2, + MOCK_SNAP_ACCOUNT_1, + MockAccountBuilder, +} from '../tests'; +import type { + AllowedActions, + AllowedEvents, + MultichainAccountControllerActions, + MultichainAccountControllerEvents, +} from '../types'; + +class MockSolanaKeyring { + readonly type = 'MockSolanaKeyring'; + + readonly metadata: KeyringMetadata = { + id: 'mock-solana-keyring-id', + name: '', + }; + + readonly accounts: InternalAccount[]; + + constructor(accounts: InternalAccount[]) { + this.accounts = accounts; + } + + #getIndexFromDerivationPath(derivationPath: string): number { + // eslint-disable-next-line prefer-regex-literals + const derivationPathIndexRegex = new RegExp( + "m/44'/501'/(?[0-9]+)'/0", + 'u', + ); + + const matched = derivationPath.match(derivationPathIndexRegex); + if (matched?.groups?.index === undefined) { + throw new Error('Unable to extract index'); + } + + const { index } = matched.groups; + return Number(index); + } + + createAccount: SnapKeyring['createAccount'] = jest + .fn() + .mockImplementation((_, options) => { + if (options.derivationPath !== undefined) { + const index = this.#getIndexFromDerivationPath(options.derivationPath); + const found = this.accounts.find( + (account) => account.options.index === index, + ); + + if (found) { + return found; // Idempotent. + } + } + + const account = MockAccountBuilder.from(MOCK_SNAP_ACCOUNT_1) + .withUuuid() + .withAddressSuffix(`${this.accounts.length}`) + .withGroupIndex(this.accounts.length) + .get(); + this.accounts.push(account); + + return account; + }); +} + +/** + * Sets up a SolAccountProvider for testing. + * + * @param options - Configuration options for setup. + * @param options.messenger - An optional messenger instance to use. Defaults to a new Messenger. + * @param options.accounts - List of accounts to use. + * @returns An object containing the controller instance and the messenger. + */ +function setup({ + messenger = getRootMessenger(), + accounts = [], +}: { + messenger?: Messenger< + MultichainAccountControllerActions | AllowedActions, + MultichainAccountControllerEvents | AllowedEvents + >; + accounts?: InternalAccount[]; +} = {}): { + provider: SolAccountProvider; + messenger: Messenger< + MultichainAccountControllerActions | AllowedActions, + MultichainAccountControllerEvents | AllowedEvents + >; + keyring: MockSolanaKeyring; + mocks: { + handleRequest: jest.Mock; + keyring: { + createAccount: jest.Mock; + }; + }; +} { + const keyring = new MockSolanaKeyring(accounts); + + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => accounts, + ); + + const mockHandleRequest = jest + .fn() + .mockImplementation((address: string) => + keyring.accounts.find((account) => account.address === address), + ); + messenger.registerActionHandler( + 'SnapController:handleRequest', + mockHandleRequest, + ); + + messenger.registerActionHandler( + 'KeyringController:withKeyring', + async (_, operation) => + operation({ + // We type-cast here, since `withKeyring` defaults to `EthKeyring` and the + // Snap keyring does really implement this interface (this is expected). + keyring: keyring as unknown as EthKeyring, + metadata: keyring.metadata, + }), + ); + + const provider = new SolAccountProvider( + getMultichainAccountControllerMessenger(messenger), + ); + + return { + provider, + messenger, + keyring, + mocks: { + handleRequest: mockHandleRequest, + keyring: { + createAccount: keyring.createAccount as jest.Mock, + }, + }, + }; +} + +describe('SolAccountProvider', () => { + it('gets accounts', () => { + const { provider } = setup({ + accounts: [MOCK_SNAP_ACCOUNT_1], + }); + + const accountsForIndex0 = provider.getAccounts({ + entropySource: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: 0, + }); + const accountsForIndex1 = provider.getAccounts({ + entropySource: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: 1, + }); + expect(accountsForIndex0).toHaveLength(1); + expect(accountsForIndex1).toHaveLength(0); + }); + + it('gets a specific account', () => { + const account = MOCK_SNAP_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + + expect(provider.getAccount(account.id)).toStrictEqual(account); + }); + + it('throws if account does not exist', () => { + const account = MOCK_SNAP_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + + const unknownAccount = MOCK_HD_ACCOUNT_1; + expect(() => provider.getAccount(unknownAccount.id)).toThrow( + `Unable to find account: ${unknownAccount.id}`, + ); + }); + + it('creates accounts', async () => { + const accounts = [MOCK_SNAP_ACCOUNT_1]; + const { provider, keyring } = setup({ + accounts, + }); + + const newGroupIndex = accounts.length; // Group-index are 0-based. + const newAccounts = await provider.createAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: newGroupIndex, + }); + expect(newAccounts).toHaveLength(1); + expect(keyring.createAccount).toHaveBeenCalled(); + }); + + it('does not re-create accounts (idempotent)', async () => { + const accounts = [MOCK_SNAP_ACCOUNT_1]; + const { provider } = setup({ + accounts, + }); + + const newAccounts = await provider.createAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + expect(newAccounts).toHaveLength(1); + expect(newAccounts[0]).toStrictEqual(MOCK_SNAP_ACCOUNT_1.id); + }); + + it('discover accounts', async () => { + const { provider, mocks } = setup({ + accounts: [], // No accounts by defaults, so we can discover them + }); + + // Discovery. + mocks.handleRequest.mockImplementationOnce(() => { + return [ + { + type: 'bip44', + derivationPath: "m/44'/501'/0'/0'", + scopes: [SolScope.Mainnet, SolScope.Devnet, SolScope.Testnet], + } as DiscoveredAccount, + ]; + }); + + // Then, create account. + mocks.keyring.createAccount.mockImplementationOnce(() => { + return MOCK_SNAP_ACCOUNT_1; + }); + + const accounts = await provider.discoverAndCreateAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + expect(accounts).toHaveLength(1); + expect(mocks.handleRequest).toHaveBeenCalledTimes(1); // Discovery (0). + expect(mocks.keyring.createAccount).toHaveBeenCalledTimes(1); + + // Discovery (but with no result). + mocks.handleRequest.mockImplementationOnce(() => { + return []; + }); + + // For now, we cannot beyond index 0 for the discovery. + const noAccounts = await provider.discoverAndCreateAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 1, + }); + expect(noAccounts).toHaveLength(0); + expect(mocks.handleRequest).toHaveBeenCalledTimes(2); // Discovery (1). + }); +}); diff --git a/packages/multichain-account-controller/src/providers/SolAccountProvider.ts b/packages/multichain-account-controller/src/providers/SolAccountProvider.ts new file mode 100644 index 00000000000..bf8a99dffe2 --- /dev/null +++ b/packages/multichain-account-controller/src/providers/SolAccountProvider.ts @@ -0,0 +1,121 @@ +import type { SnapKeyring } from '@metamask/eth-snap-keyring'; +import { + SolAccountType, + SolScope, + type EntropySourceId, +} from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { KeyringClient } from '@metamask/keyring-snap-client'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import { BaseAccountProvider } from './BaseAccountProvider'; +import type { MultichainAccountControllerMessenger } from '../types'; + +export class SolAccountProvider extends BaseAccountProvider { + static SOLANA_SNAP_ID = 'npm:@metamask/solana-wallet-snap' as SnapId; + + readonly #client: KeyringClient; + + constructor(messenger: MultichainAccountControllerMessenger) { + super(messenger); + + // TODO: Change this once we introduce 1 Snap keyring per Snaps. + this.#client = this.#getKeyringClientFromSnapId( + SolAccountProvider.SOLANA_SNAP_ID, + ); + } + + isAccountCompatible(account: InternalAccount): boolean { + return ( + account.type === SolAccountType.DataAccount && + account.metadata.keyring.type === (KeyringTypes.snap as string) + ); + } + + #getKeyringClientFromSnapId(snapId: string): KeyringClient { + return new KeyringClient({ + send: async (request: JsonRpcRequest) => { + const response = await this.messenger.call( + 'SnapController:handleRequest', + { + snapId: snapId as SnapId, + origin: 'metamask', + handler: HandlerType.OnKeyringRequest, + request, + }, + ); + return response as Promise; + }, + }); + } + + async #createAccount(opts: { + entropySource: EntropySourceId; + derivationPath: `m/${string}`; + }) { + // NOTE: We're not supposed to make the keyring instance escape `withKeyring` but + // we have to use the `SnapKeyring` instance to be able to create Solana account + // without triggering UI confirmation. + // Also, creating account that way won't invalidate the snap keyring state. The + // account will get created and persisted properly with the Snap account creation + // flow "asynchronously" (with `notify:accountCreated`). + const createAccount = await this.withKeyring< + SnapKeyring, + SnapKeyring['createAccount'] + >({ type: KeyringTypes.snap }, async ({ keyring }) => + keyring.createAccount.bind(keyring), + ); + + // Create account without any confirmation nor selecting it. + const keyringAccount = await createAccount( + SolAccountProvider.SOLANA_SNAP_ID, + opts, + { + displayAccountNameSuggestion: false, + displayConfirmation: false, + setSelectedAccount: false, + }, + ); + + return keyringAccount.id; + } + + async createAccounts({ + entropySource, + groupIndex, + }: { + entropySource: EntropySourceId; + groupIndex: number; + }) { + const id = await this.#createAccount({ + entropySource, + derivationPath: `m/44'/501'/${groupIndex}'/0'`, + }); + + return [id]; + } + + async discoverAndCreateAccounts({ + entropySource, + groupIndex, + }: { + entropySource: EntropySourceId; + groupIndex: number; + }) { + const discoveredAccounts = await this.#client.discoverAccounts( + [SolScope.Mainnet, SolScope.Testnet], + entropySource, + groupIndex, + ); + + return await Promise.all( + discoveredAccounts.map( + async ({ derivationPath }) => + await this.#createAccount({ entropySource, derivationPath }), + ), + ); + } +} diff --git a/packages/multichain-account-controller/src/tests/accounts.ts b/packages/multichain-account-controller/src/tests/accounts.ts new file mode 100644 index 00000000000..a45e631c58e --- /dev/null +++ b/packages/multichain-account-controller/src/tests/accounts.ts @@ -0,0 +1,181 @@ +import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; +import { + EthAccountType, + EthMethod, + EthScope, + SolAccountType, + SolMethod, + SolScope, +} from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { v4 as uuid } from 'uuid'; + +const ETH_EOA_METHODS = [ + EthMethod.PersonalSign, + EthMethod.Sign, + EthMethod.SignTransaction, + EthMethod.SignTypedDataV1, + EthMethod.SignTypedDataV3, + EthMethod.SignTypedDataV4, +] as const; + +const SOL_METHODS = Object.values(SolMethod); + +export const MOCK_SNAP_1 = { + id: 'local:mock-snap-id-1', + name: 'Mock Snap 1', + enabled: true, + manifest: { + proposedName: 'Mock Snap 1', + }, +}; + +export const MOCK_SNAP_2 = { + id: 'local:mock-snap-id-2', + name: 'Mock Snap 2', + enabled: true, + manifest: { + proposedName: 'Mock Snap 2', + }, +}; + +export const MOCK_ENTROPY_SOURCE_1 = 'mock-keyring-id-1'; +export const MOCK_ENTROPY_SOURCE_2 = 'mock-keyring-id-2'; + +export const MOCK_HD_KEYRING_1 = { + type: KeyringTypes.hd, + metadata: { id: MOCK_ENTROPY_SOURCE_1, name: 'HD Keyring 1' }, + accounts: ['0x123'], +}; + +export const MOCK_HD_KEYRING_2 = { + type: KeyringTypes.hd, + metadata: { id: MOCK_ENTROPY_SOURCE_2, name: 'HD Keyring 2' }, + accounts: ['0x456'], +}; + +export const MOCK_HD_ACCOUNT_1: InternalAccount = { + id: 'mock-id-1', + address: '0x123', + options: { + entropySource: MOCK_HD_KEYRING_1.metadata.id, + index: 0, + }, + methods: [...ETH_EOA_METHODS], + type: EthAccountType.Eoa, + scopes: [EthScope.Eoa], + metadata: { + name: 'Account 1', + keyring: { type: KeyringTypes.hd }, + importTime: 0, + lastSelected: 0, + nameLastUpdatedAt: 0, + }, +}; + +export const MOCK_HD_ACCOUNT_2: InternalAccount = { + id: 'mock-id-2', + address: '0x456', + options: { + entropySource: MOCK_HD_KEYRING_2.metadata.id, + index: 0, + }, + methods: [...ETH_EOA_METHODS], + type: EthAccountType.Eoa, + scopes: [EthScope.Eoa], + metadata: { + name: 'Account 2', + keyring: { type: KeyringTypes.hd }, + importTime: 0, + lastSelected: 0, + nameLastUpdatedAt: 0, + }, +}; + +export const MOCK_SNAP_ACCOUNT_1: InternalAccount = { + id: 'mock-snap-id-1', + address: 'aabbccdd', + options: { + entropySource: MOCK_HD_KEYRING_2.metadata.id, + index: 0, + }, // Note: shares entropy with MOCK_HD_ACCOUNT_2 + methods: SOL_METHODS, + type: SolAccountType.DataAccount, + scopes: [SolScope.Mainnet], + metadata: { + name: 'Snap Account 1', + keyring: { type: KeyringTypes.snap }, + snap: MOCK_SNAP_1, + importTime: 0, + lastSelected: 0, + }, +}; + +export const MOCK_SNAP_ACCOUNT_2: InternalAccount = { + id: 'mock-snap-id-2', + address: '0x789', + options: {}, + methods: [...ETH_EOA_METHODS], + type: EthAccountType.Eoa, + scopes: [EthScope.Eoa], + metadata: { + name: 'Snap Acc 2', + keyring: { type: KeyringTypes.snap }, + snap: MOCK_SNAP_2, + importTime: 0, + lastSelected: 0, + }, +}; + +export const MOCK_HARDWARE_ACCOUNT_1: InternalAccount = { + id: 'mock-hardware-id-1', + address: '0xABC', + options: {}, + methods: [...ETH_EOA_METHODS], + type: EthAccountType.Eoa, + scopes: [EthScope.Eoa], + metadata: { + name: 'Hardware Acc 1', + keyring: { type: KeyringTypes.ledger }, + importTime: 0, + lastSelected: 0, + }, +}; + +export class MockAccountBuilder { + readonly #account: InternalAccount; + + constructor(account: InternalAccount) { + // Make a deep-copy to avoid mutating the same ref. + this.#account = JSON.parse(JSON.stringify(account)); + } + + static from(account: InternalAccount): MockAccountBuilder { + return new MockAccountBuilder(account); + } + + withUuuid() { + this.#account.id = uuid(); + return this; + } + + withAddressSuffix(suffix: string) { + this.#account.address += suffix; + return this; + } + + withEntropySource(entropySource: EntropySourceId) { + this.#account.options.entropySource = entropySource; + return this; + } + + withGroupIndex(groupIndex: number) { + this.#account.options.index = groupIndex; + return this; + } + + get() { + return this.#account; + } +} diff --git a/packages/multichain-account-controller/src/tests/index.ts b/packages/multichain-account-controller/src/tests/index.ts new file mode 100644 index 00000000000..69176bd5f7f --- /dev/null +++ b/packages/multichain-account-controller/src/tests/index.ts @@ -0,0 +1,2 @@ +export * from './accounts'; +export * from './messenger'; diff --git a/packages/multichain-account-controller/src/tests/messenger.ts b/packages/multichain-account-controller/src/tests/messenger.ts new file mode 100644 index 00000000000..4e5f1045308 --- /dev/null +++ b/packages/multichain-account-controller/src/tests/messenger.ts @@ -0,0 +1,44 @@ +import { Messenger } from '@metamask/base-controller'; + +import type { + AllowedActions, + AllowedEvents, + MultichainAccountControllerActions, + MultichainAccountControllerEvents, + MultichainAccountControllerMessenger, +} from '../types'; + +/** + * Creates a new root messenger instance for testing. + * + * @returns A new Messenger instance. + */ +export function getRootMessenger() { + return new Messenger< + MultichainAccountControllerActions | AllowedActions, + MultichainAccountControllerEvents | AllowedEvents + >(); +} + +/** + * Retrieves a restricted messenger for the MultichainAccountController. + * + * @param messenger - The root messenger instance. Defaults to a new Messenger created by getRootMessenger(). + * @returns The restricted messenger for the MultichainAccountController. + */ +export function getMultichainAccountControllerMessenger( + messenger: ReturnType, +): MultichainAccountControllerMessenger { + return messenger.getRestricted({ + name: 'MultichainAccountController', + allowedEvents: [], + allowedActions: [ + 'AccountsController:getAccount', + 'AccountsController:getAccountByAddress', + 'AccountsController:listMultichainAccounts', + 'SnapController:handleRequest', + 'KeyringController:withKeyring', + 'KeyringController:getState', + ], + }); +} diff --git a/packages/multichain-account-controller/src/types.ts b/packages/multichain-account-controller/src/types.ts new file mode 100644 index 00000000000..b37e56d69ed --- /dev/null +++ b/packages/multichain-account-controller/src/types.ts @@ -0,0 +1,52 @@ +import type { + AccountsControllerGetAccountAction, + AccountsControllerGetAccountByAddressAction, + AccountsControllerListMultichainAccountsAction, +} from '@metamask/accounts-controller'; +import type { RestrictedMessenger } from '@metamask/base-controller'; +import type { + KeyringControllerGetStateAction, + KeyringControllerWithKeyringAction, +} from '@metamask/keyring-controller'; +import type { HandleSnapRequest as SnapControllerHandleSnapRequestAction } from '@metamask/snaps-controllers'; + +/** + * All actions that {@link MultichainAccountController} registers so that other + * modules can call them. + */ +export type MultichainAccountControllerActions = never; +/** + * All events that {@link MultichainAccountController} publishes so that other modules + * can subscribe to them. + */ +export type MultichainAccountControllerEvents = never; + +/** + * All actions registered by other modules that {@link MultichainAccountController} + * calls. + */ +export type AllowedActions = + | AccountsControllerListMultichainAccountsAction + | AccountsControllerGetAccountAction + | AccountsControllerGetAccountByAddressAction + | SnapControllerHandleSnapRequestAction + | KeyringControllerWithKeyringAction + | KeyringControllerGetStateAction; + +/** + * All events published by other modules that {@link MultichainAccountController} + * subscribes to. + */ +export type AllowedEvents = never; + +/** + * The messenger restricted to actions and events that + * {@link MultichainAccountController} needs to access. + */ +export type MultichainAccountControllerMessenger = RestrictedMessenger< + 'MultichainAccountController', + MultichainAccountControllerActions | AllowedActions, + MultichainAccountControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; diff --git a/packages/multichain-account-controller/tsconfig.build.json b/packages/multichain-account-controller/tsconfig.build.json new file mode 100644 index 00000000000..1f4e9685f83 --- /dev/null +++ b/packages/multichain-account-controller/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../account-tree-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/multichain-account-controller/tsconfig.json b/packages/multichain-account-controller/tsconfig.json new file mode 100644 index 00000000000..875330b9270 --- /dev/null +++ b/packages/multichain-account-controller/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../accounts-controller" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/multichain-account-controller/typedoc.json b/packages/multichain-account-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/multichain-account-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 645e49bf322..22e00decb2f 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -28,6 +28,7 @@ { "path": "./packages/logging-controller/tsconfig.build.json" }, { "path": "./packages/message-manager/tsconfig.build.json" }, { "path": "./packages/messenger/tsconfig.build.json" }, + { "path": "./packages/multichain-account-controller/tsconfig.build.json" }, { "path": "./packages/multichain-api-middleware/tsconfig.build.json" }, { "path": "./packages/multichain-network-controller/tsconfig.build.json" }, { diff --git a/tsconfig.json b/tsconfig.json index 23293be18e9..fb819e0ab5f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,7 @@ { "path": "./packages/keyring-controller" }, { "path": "./packages/message-manager" }, { "path": "./packages/messenger" }, + { "path": "./packages/multichain-account-controller" }, { "path": "./packages/multichain-api-middleware" }, { "path": "./packages/multichain-network-controller" }, { "path": "./packages/multichain-transactions-controller" }, diff --git a/yarn.lock b/yarn.lock index 71ef6e5aeb0..bcaa81d25f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3639,7 +3639,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:^18.0.0": +"@metamask/keyring-api@npm:18.0.0, @metamask/keyring-api@npm:^18.0.0": version: 18.0.0 resolution: "@metamask/keyring-api@npm:18.0.0" dependencies: @@ -3731,7 +3731,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-utils@npm:^3.0.0": +"@metamask/keyring-utils@npm:3.0.0, @metamask/keyring-utils@npm:^3.0.0": version: 3.0.0 resolution: "@metamask/keyring-utils@npm:3.0.0" dependencies: @@ -3807,6 +3807,54 @@ __metadata: languageName: node linkType: hard +"@metamask/multichain-account-api@npm:@metamask-previews/multichain-account-api@0.0.0-5ab4699": + version: 0.0.0-5ab4699 + resolution: "@metamask-previews/multichain-account-api@npm:0.0.0-5ab4699" + dependencies: + "@metamask/keyring-api": "npm:18.0.0" + "@metamask/keyring-utils": "npm:3.0.0" + "@metamask/utils": "npm:^11.1.0" + checksum: 10/5a781f8e77f592ad1fb515e87f88c03f0d4fbf02f38f5de0e4c7996fb0db40c4fe3099e310bf13cee111d8eeeec7c15ef96e5c87343ba9ef513cc7db92b1952c + languageName: node + linkType: hard + +"@metamask/multichain-account-controller@workspace:packages/multichain-account-controller": + version: 0.0.0-use.local + resolution: "@metamask/multichain-account-controller@workspace:packages/multichain-account-controller" + dependencies: + "@metamask/accounts-controller": "npm:^31.0.0" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^8.0.1" + "@metamask/eth-snap-keyring": "npm:^13.0.0" + "@metamask/keyring-api": "npm:^18.0.0" + "@metamask/keyring-controller": "npm:^22.0.2" + "@metamask/keyring-internal-api": "npm:^6.2.0" + "@metamask/keyring-snap-client": "npm:^5.0.0" + "@metamask/multichain-account-api": "npm:^0.0.0" + "@metamask/providers": "npm:^22.1.0" + "@metamask/snaps-controllers": "npm:^12.3.1" + "@metamask/snaps-sdk": "npm:^7.1.0" + "@metamask/snaps-utils": "npm:^9.4.0" + "@types/jest": "npm:^27.4.1" + "@types/uuid": "npm:^8.3.0" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.2.2" + uuid: "npm:^8.3.2" + webextension-polyfill: "npm:^0.12.0" + peerDependencies: + "@metamask/accounts-controller": ^31.0.0 + "@metamask/keyring-controller": ^22.0.0 + "@metamask/multichain-account-api": ^0.0.0 + "@metamask/providers": ^22.0.0 + "@metamask/snaps-controllers": ^12.0.0 + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + languageName: unknown + linkType: soft + "@metamask/multichain-api-middleware@workspace:packages/multichain-api-middleware": version: 0.0.0-use.local resolution: "@metamask/multichain-api-middleware@workspace:packages/multichain-api-middleware" From 23e91aedd95470072d4758185c0f718f7be816b8 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 17 Jul 2025 17:28:27 +0200 Subject: [PATCH 02/27] refactor: remove write operations + use new keyring account options --- package.json | 5 +- .../package.json | 12 +- .../src/MultichainAccountController.test.ts | 196 +----------------- .../src/MultichainAccountController.ts | 37 +--- .../src/providers/BaseAccountProvider.test.ts | 18 +- .../src/providers/BaseAccountProvider.ts | 82 ++------ .../src/providers/EvmAccountProvider.test.ts | 95 +-------- .../src/providers/EvmAccountProvider.ts | 73 +------ .../src/providers/SolAccountProvider.test.ts | 92 +------- .../src/providers/SolAccountProvider.ts | 106 +--------- .../src/tests/accounts.test.ts | 41 ++++ .../src/tests/accounts.ts | 46 +++- yarn.lock | 77 ++++--- 13 files changed, 184 insertions(+), 696 deletions(-) create mode 100644 packages/multichain-account-controller/src/tests/accounts.test.ts diff --git a/package.json b/package.json index 3311358e70f..4b602cc3ec2 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,10 @@ "elliptic@6.5.4": "^6.5.7", "fast-xml-parser@^4.3.4": "^4.4.1", "ws@7.4.6": "^7.5.10", - "@metamask/multichain-account-api@^0.0.0": "npm:@metamask-previews/multichain-account-api@0.0.0-5ab4699" + "@metamask/keyring-utils@^3.0.0": "npm:@metamask-previews/keyring-utils@3.0.0-e86c8ec", + "@metamask/keyring-api@^18.0.0": "npm:@metamask-previews/keyring-api@18.0.0-e86c8ec", + "@metamask/keyring-internal-api@^6.2.0": "npm:@metamask-previews/keyring-internal-api@6.2.0-e86c8ec", + "@metamask/account-api@^0.1.0": "npm:@metamask-previews/account-api@0.1.0-e86c8ec" }, "devDependencies": { "@babel/core": "^7.23.5", diff --git a/packages/multichain-account-controller/package.json b/packages/multichain-account-controller/package.json index 1947241635c..8798ee14310 100644 --- a/packages/multichain-account-controller/package.json +++ b/packages/multichain-account-controller/package.json @@ -47,14 +47,15 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/account-api": "^0.1.0", "@metamask/base-controller": "^8.0.1", "@metamask/keyring-api": "^18.0.0", "@metamask/keyring-controller": "^19.0.0", "@metamask/keyring-internal-api": "^6.2.0", "@metamask/keyring-snap-client": "^5.0.0", - "@metamask/multichain-account-api": "^0.0.0", - "@metamask/snaps-sdk": "^7.1.0", - "@metamask/snaps-utils": "^9.4.0" + "@metamask/snaps-sdk": "^9.0.0", + "@metamask/snaps-utils": "^11.0.0", + "@metamask/superstruct": "^3.1.0" }, "devDependencies": { "@metamask/accounts-controller": "^31.0.0", @@ -62,7 +63,7 @@ "@metamask/eth-snap-keyring": "^13.0.0", "@metamask/keyring-controller": "^22.0.2", "@metamask/providers": "^22.1.0", - "@metamask/snaps-controllers": "^12.3.1", + "@metamask/snaps-controllers": "^14.0.1", "@types/jest": "^27.4.1", "@types/uuid": "^8.3.0", "deepmerge": "^4.2.2", @@ -77,9 +78,8 @@ "peerDependencies": { "@metamask/accounts-controller": "^31.0.0", "@metamask/keyring-controller": "^22.0.0", - "@metamask/multichain-account-api": "^0.0.0", "@metamask/providers": "^22.0.0", - "@metamask/snaps-controllers": "^12.0.0", + "@metamask/snaps-controllers": "^14.0.0", "webextension-polyfill": "^0.10.0 || ^0.11.0 || ^0.12.0" }, "engines": { diff --git a/packages/multichain-account-controller/src/MultichainAccountController.test.ts b/packages/multichain-account-controller/src/MultichainAccountController.test.ts index 7dd537cc2b3..7a5c64c2dc7 100644 --- a/packages/multichain-account-controller/src/MultichainAccountController.test.ts +++ b/packages/multichain-account-controller/src/MultichainAccountController.test.ts @@ -1,6 +1,6 @@ /* eslint-disable jsdoc/require-jsdoc */ import type { Messenger } from '@metamask/base-controller'; -import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; +import type { KeyringAccount } from '@metamask/keyring-api'; import { EthAccountType, SolAccountType } from '@metamask/keyring-api'; import type { KeyringObject } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; @@ -66,27 +66,9 @@ function mockAccountProvider( .mocked(providerClass) .mockImplementation(() => mocks as unknown as Provider); - mocks.getAccounts.mockImplementation( - ({ - entropySource, - groupIndex, - }: { - entropySource: EntropySourceId; - groupIndex: number; - }) => - accounts - .filter( - (account) => - account.type === type && - account.options.entropySource === entropySource && - account.options.index === groupIndex, - ) - .map((account) => account.id), + mocks.getAccounts.mockImplementation(() => + accounts.filter((account) => account.type === type), ); - - mocks.getAccount.mockImplementation((id: InternalAccount['id']) => { - return accounts.find((account) => account.id === id); - }); } function setup({ @@ -296,176 +278,4 @@ describe('MultichainAccountController', () => { ).toThrow(`No multichain account for index: ${groupIndex}`); }); }); - - describe('createNextMultichainAccount', () => { - it('creates the next multichain account', async () => { - // Used to build the initial wallet with 1 multichain account (for - // group index 0)! - const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) - .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) - .withGroupIndex(0) - .get(); - - const { controller, mocks } = setup({ accounts: [mockEvmAccount] }); - - // Before creating the next multichain account, we need to mock some actions: - const mockNextEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_2) - .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) - .withGroupIndex(1) - .get(); - const mockNextSolAccount = MockAccountBuilder.from(MOCK_SNAP_ACCOUNT_1) - .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) - .withGroupIndex(1) - .withUuuid() // Required by KeyringClient. - .get(); - - // We need to mock every call made to the providers when creating an accounts: - for (const [mocksAccountProvider, mockNextAccount] of [ - [mocks.EvmAccountProvider, mockNextEvmAccount], - [mocks.SolAccountProvider, mockNextSolAccount], - ] as const) { - // 1. Create the accounts for the new index and returns their IDs. - mocksAccountProvider.createAccounts.mockResolvedValueOnce([ - mockNextAccount.id, - ]); - // 2. When the adapter creates a new multichain account, it will query all - // accounts for this given index (so similar to the one we just created). - mocksAccountProvider.getAccounts.mockReturnValueOnce([mockNextAccount]); - // 3. Required when we call `getAccounts` (below) on the multichain account. - mocksAccountProvider.getAccount.mockReturnValueOnce(mockNextAccount); - } - - const multichainAccount = await controller.createNextMultichainAccount({ - entropySource: MOCK_HD_KEYRING_1.metadata.id, - }); - expect(multichainAccount.index).toBe(1); - - const internalAccounts = multichainAccount.getAccounts(); - expect(internalAccounts).toHaveLength(2); // EVM + SOL. - expect(internalAccounts[0].type).toBe(EthAccountType.Eoa); - expect(internalAccounts[1].type).toBe(SolAccountType.DataAccount); - }); - }); - - describe('discoverAndCreateMultichainAccounts', () => { - it('discovers and creates multichain accounts', async () => { - // Starts with no accounts, to simulate the discovery. - const { controller, mocks } = setup({ accounts: [] }); - - // We need to mock every call made to the providers when discovery an accounts: - for (const [mocksAccountProvider, mockDiscoveredAccount] of [ - [mocks.EvmAccountProvider, MOCK_HD_ACCOUNT_1], - [mocks.SolAccountProvider, MOCK_SNAP_ACCOUNT_1], - ] as const) { - mocksAccountProvider.discoverAndCreateAccounts.mockResolvedValueOnce([ - mockDiscoveredAccount.id, // Account that got discovered and created. - ]); - mocksAccountProvider.discoverAndCreateAccounts.mockResolvedValueOnce( - [], // Stop the discovery. - ); - mocksAccountProvider.getAccounts.mockReturnValue([ - mockDiscoveredAccount.id, // Account that got created during discovery. - ]); - mocksAccountProvider.getAccount.mockReturnValue(mockDiscoveredAccount); - } - - const multichainAccounts = - await controller.discoverAndCreateMultichainAccounts({ - entropySource: MOCK_HD_KEYRING_1.metadata.id, - }); - // We only discover 1 account on each providers, which should only have 1 multichain - // account. - expect(multichainAccounts).toHaveLength(1); - expect(multichainAccounts[0].index).toBe(0); - - const internalAccounts = multichainAccounts[0].getAccounts(); - expect(internalAccounts).toHaveLength(2); // EVM + SOL. - expect(internalAccounts[0].type).toBe(EthAccountType.Eoa); - expect(internalAccounts[1].type).toBe(SolAccountType.DataAccount); - }); - - it('discovers and creates multichain accounts for multiple index', async () => { - // Starts with no accounts, to simulate the discovery. - const { controller, mocks } = setup({ accounts: [] }); - - const maxGroupIndex = 10; - for (let i = 0; i < maxGroupIndex; i++) { - // We need to mock every call made to the providers when discovery an accounts: - for (const [mocksAccountProvider, mockDiscoveredAccount] of [ - [mocks.EvmAccountProvider, MOCK_HD_ACCOUNT_1], - [mocks.SolAccountProvider, MOCK_SNAP_ACCOUNT_1], - ] as const) { - const mockDiscoveredAccountForIndex = MockAccountBuilder.from( - mockDiscoveredAccount, - ) - .withGroupIndex(i) - .withUuuid() - .get(); - - mocksAccountProvider.discoverAndCreateAccounts.mockResolvedValueOnce([ - mockDiscoveredAccountForIndex.id, // Account that got discovered and created. - ]); - } - } - - // Stop the discoveries. - mocks.EvmAccountProvider.discoverAndCreateAccounts.mockResolvedValueOnce( - [], - ); - mocks.SolAccountProvider.discoverAndCreateAccounts.mockResolvedValueOnce( - [], - ); - - const multichainAccounts = - await controller.discoverAndCreateMultichainAccounts({ - entropySource: MOCK_HD_KEYRING_1.metadata.id, - }); - expect(multichainAccounts).toHaveLength(maxGroupIndex); - }); - - it('discovers and creates multichain accounts and fill gaps (alignmnent mechanism)', async () => { - // Starts with no accounts, to simulate the discovery. - const { controller, mocks } = setup({ accounts: [] }); - - // We only mock calls for the EVM providers, the Solana provider won't discovery anything. - const mocksAccountProvider = mocks.EvmAccountProvider; - const mockDiscoveredAccount = MOCK_HD_ACCOUNT_1; - mocksAccountProvider.discoverAndCreateAccounts.mockResolvedValueOnce([ - mockDiscoveredAccount.id, // Account that got discovered and created. - ]); - mocksAccountProvider.discoverAndCreateAccounts.mockResolvedValueOnce( - [], // Stop the discovery. - ); - mocksAccountProvider.getAccounts.mockReturnValue([ - mockDiscoveredAccount.id, // Account that got created during discovery. - ]); - mocksAccountProvider.getAccount.mockReturnValue(mockDiscoveredAccount); - - // No discovery for Solana. - mocks.SolAccountProvider.discoverAndCreateAccounts.mockResolvedValue([]); - mocks.SolAccountProvider.createAccounts.mockResolvedValue( - MOCK_SNAP_ACCOUNT_1.id, - ); - mocks.SolAccountProvider.getAccounts.mockReturnValue([ - MOCK_SNAP_ACCOUNT_1.id, - ]); - mocks.SolAccountProvider.getAccount.mockReturnValue(MOCK_SNAP_ACCOUNT_1); - - const multichainAccounts = - await controller.discoverAndCreateMultichainAccounts({ - entropySource: MOCK_HD_KEYRING_1.metadata.id, - }); - // We only discover 1 account on the EVM providers, which is still produce 1 multichain - // account. - expect(multichainAccounts).toHaveLength(1); - expect(multichainAccounts[0].index).toBe(0); - - // And Solana account must have been created too (we "aligned" all accounts). - expect(mocks.SolAccountProvider.createAccounts).toHaveBeenCalled(); - const internalAccounts = multichainAccounts[0].getAccounts(); - expect(internalAccounts).toHaveLength(2); // EVM + SOL. - expect(internalAccounts[0].type).toBe(EthAccountType.Eoa); - expect(internalAccounts[1].type).toBe(SolAccountType.DataAccount); - }); - }); }); diff --git a/packages/multichain-account-controller/src/MultichainAccountController.ts b/packages/multichain-account-controller/src/MultichainAccountController.ts index a5e2fbeecef..7afb79c90f7 100644 --- a/packages/multichain-account-controller/src/MultichainAccountController.ts +++ b/packages/multichain-account-controller/src/MultichainAccountController.ts @@ -1,16 +1,15 @@ -import type { EntropySourceId } from '@metamask/keyring-api'; -import { KeyringTypes } from '@metamask/keyring-controller'; -import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { - AccountProvider, MultichainAccountWalletId, -} from '@metamask/multichain-account-api'; + AccountProvider, +} from '@metamask/account-api'; import { - MultichainAccountWalletAdapter, + MultichainAccountWallet, toMultichainAccountWalletId, type MultichainAccount, - type MultichainAccountWallet, -} from '@metamask/multichain-account-api'; +} from '@metamask/account-api'; +import type { EntropySourceId } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; import { EvmAccountProvider } from './providers/EvmAccountProvider'; import { SolAccountProvider } from './providers/SolAccountProvider'; @@ -59,7 +58,7 @@ export class MultichainAccountController { const entropySources = []; for (const keyring of keyrings) { - if (keyring.type === KeyringTypes.hd) { + if (keyring.type === (KeyringTypes.hd as string)) { entropySources.push(keyring.metadata.id); } } @@ -67,7 +66,7 @@ export class MultichainAccountController { for (const entropySource of entropySources) { // This will automatically create all multichain accounts for that wallet (based // on the accounts owned by each account providers). - const wallet = new MultichainAccountWalletAdapter({ + const wallet = new MultichainAccountWallet({ entropySource, providers: this.#providers, }); @@ -116,22 +115,4 @@ export class MultichainAccountController { }): MultichainAccount[] { return this.#getWallet(entropySource).getMultichainAccounts(); } - - async createNextMultichainAccount({ - entropySource, - }: { - entropySource: EntropySourceId; - }): Promise> { - return await this.#getWallet(entropySource).createNextMultichainAccount(); - } - - async discoverAndCreateMultichainAccounts({ - entropySource, - }: { - entropySource: EntropySourceId; - }): Promise[]> { - return await this.#getWallet( - entropySource, - ).discoverAndCreateMultichainAccounts(); - } } diff --git a/packages/multichain-account-controller/src/providers/BaseAccountProvider.test.ts b/packages/multichain-account-controller/src/providers/BaseAccountProvider.test.ts index 4277439b244..c9be7f905f7 100644 --- a/packages/multichain-account-controller/src/providers/BaseAccountProvider.test.ts +++ b/packages/multichain-account-controller/src/providers/BaseAccountProvider.test.ts @@ -1,3 +1,4 @@ +import { KeyringAccountEntropyTypeOption } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { Json } from '@metamask/utils'; @@ -14,15 +15,26 @@ describe('isBip44Account', () => { }); it.each([ - { tc: 'no entropy', options: { entropySource: undefined } }, - { tc: 'no index', options: { index: undefined } }, + { + tc: 'no entropy options', + options: { + // No entropy + }, + }, + { + tc: 'invalid entropy type', + options: { + entropy: { + type: KeyringAccountEntropyTypeOption.PrivateKey, + }, + }, + }, ])( 'returns false if an account is not BIP-44 compatible: $tc', ({ options }) => { const account: InternalAccount = { ...MOCK_HD_ACCOUNT_1, options: { - ...MOCK_HD_ACCOUNT_1.options, ...options, } as unknown as Record, // To allow `undefined` values. }; diff --git a/packages/multichain-account-controller/src/providers/BaseAccountProvider.ts b/packages/multichain-account-controller/src/providers/BaseAccountProvider.ts index 980af797b2a..f471cef1247 100644 --- a/packages/multichain-account-controller/src/providers/BaseAccountProvider.ts +++ b/packages/multichain-account-controller/src/providers/BaseAccountProvider.ts @@ -1,19 +1,17 @@ +import type { AccountProvider } from '@metamask/account-api'; import type { AccountId } from '@metamask/accounts-controller'; -import type { KeyringAccount } from '@metamask/keyring-api'; -import { type EntropySourceId } from '@metamask/keyring-api'; -import { - type KeyringMetadata, - type KeyringSelector, -} from '@metamask/keyring-controller'; +import type { + KeyringAccount, + KeyringAccountEntropyMnemonicOptions, +} from '@metamask/keyring-api'; +import { KeyringAccountEntropyTypeOption } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import type { AccountProvider } from '@metamask/multichain-account-api'; import type { MultichainAccountControllerMessenger } from '../types'; export type Bip44Account = Account & { options: { - index: number; - entropySource: EntropySourceId; + entropy: KeyringAccountEntropyMnemonicOptions; }; }; @@ -26,18 +24,12 @@ export type Bip44Account = Account & { export function isBip44Account( account: Account, ): account is Bip44Account { - // TODO: Maybe use superstruct to validate the structure of HD account since they are not strongly-typed for now? - if (!account.options.entropySource) { + if ( + !account.options.entropy || + account.options.entropy.type !== KeyringAccountEntropyTypeOption.Mnemonic + ) { console.warn( - "! Found an HD account with no entropy source: account won't be associated to its wallet.", - ); - return false; - } - - // TODO: We need to add this index for native accounts too! - if (account.options.index === undefined) { - console.warn( - "! Found an HD account with no index: account won't be associated to its wallet.", + "! Found an HD account with invalid entropy options: account won't be associated to its wallet.", ); return false; } @@ -54,31 +46,8 @@ export abstract class BaseAccountProvider this.messenger = messenger; } - protected async withKeyring( - selector: KeyringSelector, - operation: ({ - keyring, - metadata, - }: { - keyring: SelectedKeyring; - metadata: KeyringMetadata; - }) => Promise, - ): Promise { - const result = await this.messenger.call( - 'KeyringController:withKeyring', - selector, - ({ keyring, metadata }) => - operation({ - keyring: keyring as SelectedKeyring, - metadata, - }), - ); - - return result as CallbackResult; - } - #getAccounts( - filter: (account: InternalAccount) => boolean, + filter: (account: InternalAccount) => boolean = () => true, ): Bip44Account[] { const accounts: Bip44Account[] = []; @@ -97,19 +66,8 @@ export abstract class BaseAccountProvider return accounts; } - getAccounts({ - entropySource, - groupIndex, - }: { - entropySource: EntropySourceId; - groupIndex: number; - }): AccountId[] { - return this.#getAccounts((account) => { - return ( - account.options.entropySource === entropySource && - account.options.index === groupIndex - ); - }).map((account) => account.id); + getAccounts(): InternalAccount[] { + return this.#getAccounts(); } getAccount(id: AccountId): InternalAccount { @@ -124,14 +82,4 @@ export abstract class BaseAccountProvider } abstract isAccountCompatible(account: InternalAccount): boolean; - - abstract createAccounts(opts: { - entropySource: EntropySourceId; - groupIndex: number; - }): Promise; - - abstract discoverAndCreateAccounts(opts: { - entropySource: EntropySourceId; - groupIndex: number; - }): Promise; } diff --git a/packages/multichain-account-controller/src/providers/EvmAccountProvider.test.ts b/packages/multichain-account-controller/src/providers/EvmAccountProvider.test.ts index 0afdb22519e..d0e50f2c8d7 100644 --- a/packages/multichain-account-controller/src/providers/EvmAccountProvider.test.ts +++ b/packages/multichain-account-controller/src/providers/EvmAccountProvider.test.ts @@ -11,7 +11,6 @@ import { getRootMessenger, MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2, - MOCK_HD_KEYRING_1, MockAccountBuilder, } from '../tests'; import type { @@ -54,7 +53,7 @@ class MockEthKeyring implements EthKeyring { for (let i = 0; i < numberOfAccounts; i++) { this.accounts.push( MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) - .withUuuid() + .withUuid() .withAddressSuffix(`${this.accounts.length}`) .get(), ); @@ -132,20 +131,12 @@ function setup({ describe('EvmAccountProvider', () => { it('gets accounts', () => { + const accounts = [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2]; const { provider } = setup({ - accounts: [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2], + accounts, }); - const accountsForIndex0 = provider.getAccounts({ - entropySource: MOCK_HD_KEYRING_1.metadata.id, - groupIndex: 0, - }); - const accountsForIndex1 = provider.getAccounts({ - entropySource: MOCK_HD_KEYRING_1.metadata.id, - groupIndex: 1, - }); - expect(accountsForIndex0).toHaveLength(1); - expect(accountsForIndex1).toHaveLength(0); + expect(provider.getAccounts()).toStrictEqual(accounts); }); it('gets a specific account', () => { @@ -168,82 +159,4 @@ describe('EvmAccountProvider', () => { `Unable to find account: ${unknownAccount.id}`, ); }); - - it('creates accounts', async () => { - const accounts = [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2]; - const { provider, keyring } = setup({ - accounts, - }); - - const newGroupIndex = accounts.length; // Group-index are 0-based. - const newAccounts = await provider.createAccounts({ - entropySource: MOCK_HD_KEYRING_1.metadata.id, - groupIndex: newGroupIndex, - }); - expect(newAccounts).toHaveLength(1); - expect(keyring.getAccounts).toHaveBeenCalled(); // Checks for existing accounts. - expect(keyring.addAccounts).toHaveBeenCalledWith(1); // Create 1 account. - }); - - it('does not re-create accounts (idempotent)', async () => { - const accounts = [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2]; - const { provider } = setup({ - accounts, - }); - - const newAccounts = await provider.createAccounts({ - entropySource: MOCK_HD_KEYRING_1.metadata.id, - groupIndex: 0, - }); - expect(newAccounts).toHaveLength(1); - expect(newAccounts[0]).toStrictEqual(MOCK_HD_ACCOUNT_1.id); - }); - - it('throws when trying to create gaps', async () => { - const { provider } = setup({ - accounts: [MOCK_HD_ACCOUNT_1], - }); - - await expect( - provider.createAccounts({ - entropySource: MOCK_HD_KEYRING_1.metadata.id, - groupIndex: 10, - }), - ).rejects.toThrow('Trying to create too many accounts'); - }); - - it('throws if internal account cannot be found', async () => { - const { provider, mocks } = setup({ - accounts: [MOCK_HD_ACCOUNT_1], - }); - - // Simulate an account not found. - mocks.getAccountsByAddress.mockImplementation(() => undefined); - - await expect( - provider.createAccounts({ - entropySource: MOCK_HD_KEYRING_1.metadata.id, - groupIndex: 1, - }), - ).rejects.toThrow('Internal account does not exist'); - }); - - it('discover accounts', async () => { - const { provider } = setup({ - accounts: [], // No accounts by defaults, so we can discover them - }); - - const accounts = await provider.discoverAndCreateAccounts({ - entropySource: MOCK_HD_KEYRING_1.metadata.id, - groupIndex: 0, - }); - expect(accounts).toHaveLength(1); - - // For now, we cannot beyond index 0 for the discovery. - const noAccounts = await provider.discoverAndCreateAccounts({ - entropySource: MOCK_HD_KEYRING_1.metadata.id, - groupIndex: 1, - }); - expect(noAccounts).toHaveLength(0); - }); }); diff --git a/packages/multichain-account-controller/src/providers/EvmAccountProvider.ts b/packages/multichain-account-controller/src/providers/EvmAccountProvider.ts index 23b9750cf64..d950baca9f3 100644 --- a/packages/multichain-account-controller/src/providers/EvmAccountProvider.ts +++ b/packages/multichain-account-controller/src/providers/EvmAccountProvider.ts @@ -1,25 +1,9 @@ -import { EthAccountType, type EntropySourceId } from '@metamask/keyring-api'; +import { EthAccountType } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; -import type { - EthKeyring, - InternalAccount, -} from '@metamask/keyring-internal-api'; -import type { Hex } from '@metamask/utils'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; import { BaseAccountProvider } from './BaseAccountProvider'; -// Max index used by discovery (until we move the proper discovery here). -const MAX_GROUP_INDEX = 1; - -// eslint-disable-next-line jsdoc/require-jsdoc -function assertInternalAccountExists( - account: InternalAccount | undefined, -): asserts account is InternalAccount { - if (!account) { - throw new Error('Internal account does not exist'); - } -} - export class EvmAccountProvider extends BaseAccountProvider { isAccountCompatible(account: InternalAccount): boolean { return ( @@ -27,57 +11,4 @@ export class EvmAccountProvider extends BaseAccountProvider { account.metadata.keyring.type === (KeyringTypes.hd as string) ); } - - async createAccounts({ - entropySource, - groupIndex, - }: { - entropySource: EntropySourceId; - groupIndex: number; - }) { - const [address] = await this.withKeyring( - { id: entropySource }, - async ({ keyring }) => { - const accounts = await keyring.getAccounts(); - if (groupIndex < accounts.length) { - // Nothing new to create, we just re-use the existing accounts here, - return [accounts[groupIndex]]; - } - - // For now, we don't allow for gap, so if we need to create a new - // account, this has to be the next one. - if (groupIndex !== accounts.length) { - throw new Error('Trying to create too many accounts'); - } - - // Create next account (and returns their addresses). - return await keyring.addAccounts(1); - }, - ); - - const account = this.messenger.call( - 'AccountsController:getAccountByAddress', - address, - ); - - // We MUST have the associated internal account. - assertInternalAccountExists(account); - - return [account.id]; - } - - override async discoverAndCreateAccounts({ - entropySource, - groupIndex, - }: { - entropySource: EntropySourceId; - groupIndex: number; - }) { - // TODO: Move account discovery here (for EVM). - - if (groupIndex < MAX_GROUP_INDEX) { - return await this.createAccounts({ entropySource, groupIndex }); - } - return []; - } } diff --git a/packages/multichain-account-controller/src/providers/SolAccountProvider.test.ts b/packages/multichain-account-controller/src/providers/SolAccountProvider.test.ts index 214fb8b9cca..e5cc6126433 100644 --- a/packages/multichain-account-controller/src/providers/SolAccountProvider.test.ts +++ b/packages/multichain-account-controller/src/providers/SolAccountProvider.test.ts @@ -1,7 +1,5 @@ import type { Messenger } from '@metamask/base-controller'; import type { SnapKeyring } from '@metamask/eth-snap-keyring'; -import type { DiscoveredAccount } from '@metamask/keyring-api'; -import { SolScope } from '@metamask/keyring-api'; import type { KeyringMetadata } from '@metamask/keyring-controller'; import type { EthKeyring, @@ -13,8 +11,6 @@ import { getMultichainAccountControllerMessenger, getRootMessenger, MOCK_HD_ACCOUNT_1, - MOCK_HD_KEYRING_1, - MOCK_HD_KEYRING_2, MOCK_SNAP_ACCOUNT_1, MockAccountBuilder, } from '../tests'; @@ -70,7 +66,7 @@ class MockSolanaKeyring { } const account = MockAccountBuilder.from(MOCK_SNAP_ACCOUNT_1) - .withUuuid() + .withUuid() .withAddressSuffix(`${this.accounts.length}`) .withGroupIndex(this.accounts.length) .get(); @@ -158,20 +154,12 @@ function setup({ describe('SolAccountProvider', () => { it('gets accounts', () => { + const accounts = [MOCK_SNAP_ACCOUNT_1]; const { provider } = setup({ - accounts: [MOCK_SNAP_ACCOUNT_1], + accounts, }); - const accountsForIndex0 = provider.getAccounts({ - entropySource: MOCK_HD_KEYRING_2.metadata.id, - groupIndex: 0, - }); - const accountsForIndex1 = provider.getAccounts({ - entropySource: MOCK_HD_KEYRING_2.metadata.id, - groupIndex: 1, - }); - expect(accountsForIndex0).toHaveLength(1); - expect(accountsForIndex1).toHaveLength(0); + expect(provider.getAccounts()).toStrictEqual(accounts); }); it('gets a specific account', () => { @@ -194,76 +182,4 @@ describe('SolAccountProvider', () => { `Unable to find account: ${unknownAccount.id}`, ); }); - - it('creates accounts', async () => { - const accounts = [MOCK_SNAP_ACCOUNT_1]; - const { provider, keyring } = setup({ - accounts, - }); - - const newGroupIndex = accounts.length; // Group-index are 0-based. - const newAccounts = await provider.createAccounts({ - entropySource: MOCK_HD_KEYRING_1.metadata.id, - groupIndex: newGroupIndex, - }); - expect(newAccounts).toHaveLength(1); - expect(keyring.createAccount).toHaveBeenCalled(); - }); - - it('does not re-create accounts (idempotent)', async () => { - const accounts = [MOCK_SNAP_ACCOUNT_1]; - const { provider } = setup({ - accounts, - }); - - const newAccounts = await provider.createAccounts({ - entropySource: MOCK_HD_KEYRING_1.metadata.id, - groupIndex: 0, - }); - expect(newAccounts).toHaveLength(1); - expect(newAccounts[0]).toStrictEqual(MOCK_SNAP_ACCOUNT_1.id); - }); - - it('discover accounts', async () => { - const { provider, mocks } = setup({ - accounts: [], // No accounts by defaults, so we can discover them - }); - - // Discovery. - mocks.handleRequest.mockImplementationOnce(() => { - return [ - { - type: 'bip44', - derivationPath: "m/44'/501'/0'/0'", - scopes: [SolScope.Mainnet, SolScope.Devnet, SolScope.Testnet], - } as DiscoveredAccount, - ]; - }); - - // Then, create account. - mocks.keyring.createAccount.mockImplementationOnce(() => { - return MOCK_SNAP_ACCOUNT_1; - }); - - const accounts = await provider.discoverAndCreateAccounts({ - entropySource: MOCK_HD_KEYRING_1.metadata.id, - groupIndex: 0, - }); - expect(accounts).toHaveLength(1); - expect(mocks.handleRequest).toHaveBeenCalledTimes(1); // Discovery (0). - expect(mocks.keyring.createAccount).toHaveBeenCalledTimes(1); - - // Discovery (but with no result). - mocks.handleRequest.mockImplementationOnce(() => { - return []; - }); - - // For now, we cannot beyond index 0 for the discovery. - const noAccounts = await provider.discoverAndCreateAccounts({ - entropySource: MOCK_HD_KEYRING_1.metadata.id, - groupIndex: 1, - }); - expect(noAccounts).toHaveLength(0); - expect(mocks.handleRequest).toHaveBeenCalledTimes(2); // Discovery (1). - }); }); diff --git a/packages/multichain-account-controller/src/providers/SolAccountProvider.ts b/packages/multichain-account-controller/src/providers/SolAccountProvider.ts index bf8a99dffe2..8d92b94f7f0 100644 --- a/packages/multichain-account-controller/src/providers/SolAccountProvider.ts +++ b/packages/multichain-account-controller/src/providers/SolAccountProvider.ts @@ -1,121 +1,17 @@ -import type { SnapKeyring } from '@metamask/eth-snap-keyring'; -import { - SolAccountType, - SolScope, - type EntropySourceId, -} from '@metamask/keyring-api'; +import { SolAccountType } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { KeyringClient } from '@metamask/keyring-snap-client'; import type { SnapId } from '@metamask/snaps-sdk'; -import { HandlerType } from '@metamask/snaps-utils'; -import type { Json, JsonRpcRequest } from '@metamask/utils'; import { BaseAccountProvider } from './BaseAccountProvider'; -import type { MultichainAccountControllerMessenger } from '../types'; export class SolAccountProvider extends BaseAccountProvider { static SOLANA_SNAP_ID = 'npm:@metamask/solana-wallet-snap' as SnapId; - readonly #client: KeyringClient; - - constructor(messenger: MultichainAccountControllerMessenger) { - super(messenger); - - // TODO: Change this once we introduce 1 Snap keyring per Snaps. - this.#client = this.#getKeyringClientFromSnapId( - SolAccountProvider.SOLANA_SNAP_ID, - ); - } - isAccountCompatible(account: InternalAccount): boolean { return ( account.type === SolAccountType.DataAccount && account.metadata.keyring.type === (KeyringTypes.snap as string) ); } - - #getKeyringClientFromSnapId(snapId: string): KeyringClient { - return new KeyringClient({ - send: async (request: JsonRpcRequest) => { - const response = await this.messenger.call( - 'SnapController:handleRequest', - { - snapId: snapId as SnapId, - origin: 'metamask', - handler: HandlerType.OnKeyringRequest, - request, - }, - ); - return response as Promise; - }, - }); - } - - async #createAccount(opts: { - entropySource: EntropySourceId; - derivationPath: `m/${string}`; - }) { - // NOTE: We're not supposed to make the keyring instance escape `withKeyring` but - // we have to use the `SnapKeyring` instance to be able to create Solana account - // without triggering UI confirmation. - // Also, creating account that way won't invalidate the snap keyring state. The - // account will get created and persisted properly with the Snap account creation - // flow "asynchronously" (with `notify:accountCreated`). - const createAccount = await this.withKeyring< - SnapKeyring, - SnapKeyring['createAccount'] - >({ type: KeyringTypes.snap }, async ({ keyring }) => - keyring.createAccount.bind(keyring), - ); - - // Create account without any confirmation nor selecting it. - const keyringAccount = await createAccount( - SolAccountProvider.SOLANA_SNAP_ID, - opts, - { - displayAccountNameSuggestion: false, - displayConfirmation: false, - setSelectedAccount: false, - }, - ); - - return keyringAccount.id; - } - - async createAccounts({ - entropySource, - groupIndex, - }: { - entropySource: EntropySourceId; - groupIndex: number; - }) { - const id = await this.#createAccount({ - entropySource, - derivationPath: `m/44'/501'/${groupIndex}'/0'`, - }); - - return [id]; - } - - async discoverAndCreateAccounts({ - entropySource, - groupIndex, - }: { - entropySource: EntropySourceId; - groupIndex: number; - }) { - const discoveredAccounts = await this.#client.discoverAccounts( - [SolScope.Mainnet, SolScope.Testnet], - entropySource, - groupIndex, - ); - - return await Promise.all( - discoveredAccounts.map( - async ({ derivationPath }) => - await this.#createAccount({ entropySource, derivationPath }), - ), - ); - } } diff --git a/packages/multichain-account-controller/src/tests/accounts.test.ts b/packages/multichain-account-controller/src/tests/accounts.test.ts new file mode 100644 index 00000000000..0af95584332 --- /dev/null +++ b/packages/multichain-account-controller/src/tests/accounts.test.ts @@ -0,0 +1,41 @@ +import { + MOCK_HD_ACCOUNT_1, + MOCK_SNAP_ACCOUNT_2, + MockAccountBuilder, +} from './accounts'; + +describe('MockAccountBuilder', () => { + it('updates the account ID', () => { + const account = MOCK_HD_ACCOUNT_1; + const mockAccount = MockAccountBuilder.from(account).withUuid().get(); + + expect(account.id).not.toStrictEqual(mockAccount.id); + }); + + it('adds a suffix to the account address', () => { + const suffix = 'foo'; + + const account = MOCK_HD_ACCOUNT_1; + const mockAccount = MockAccountBuilder.from(account) + .withAddressSuffix(suffix) + .get(); + + expect(mockAccount.address.endsWith(suffix)).toBe(true); + }); + + it('throws if trying to update entropy source for non-BIP-44 accounts', () => { + const account = MOCK_SNAP_ACCOUNT_2; // Not a BIP-44 account. + + expect(() => + MockAccountBuilder.from(account).withEntropySource('test').get(), + ).toThrow('Invalid BIP-44 account'); + }); + + it('throws if trying to update group index for non-BIP-44 accounts', () => { + const account = MOCK_SNAP_ACCOUNT_2; // Not a BIP-44 account. + + expect(() => + MockAccountBuilder.from(account).withGroupIndex(10).get(), + ).toThrow('Invalid BIP-44 account'); + }); +}); diff --git a/packages/multichain-account-controller/src/tests/accounts.ts b/packages/multichain-account-controller/src/tests/accounts.ts index a45e631c58e..d1068c832d9 100644 --- a/packages/multichain-account-controller/src/tests/accounts.ts +++ b/packages/multichain-account-controller/src/tests/accounts.ts @@ -1,8 +1,9 @@ -import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; +import type { EntropySourceId } from '@metamask/keyring-api'; import { EthAccountType, EthMethod, EthScope, + KeyringAccountEntropyTypeOption, SolAccountType, SolMethod, SolScope, @@ -11,6 +12,8 @@ import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { v4 as uuid } from 'uuid'; +import { isBip44Account } from '../providers/BaseAccountProvider'; + const ETH_EOA_METHODS = [ EthMethod.PersonalSign, EthMethod.Sign, @@ -59,8 +62,12 @@ export const MOCK_HD_ACCOUNT_1: InternalAccount = { id: 'mock-id-1', address: '0x123', options: { - entropySource: MOCK_HD_KEYRING_1.metadata.id, - index: 0, + entropy: { + type: KeyringAccountEntropyTypeOption.Mnemonic, + id: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + derivationPath: '', + }, }, methods: [...ETH_EOA_METHODS], type: EthAccountType.Eoa, @@ -78,8 +85,12 @@ export const MOCK_HD_ACCOUNT_2: InternalAccount = { id: 'mock-id-2', address: '0x456', options: { - entropySource: MOCK_HD_KEYRING_2.metadata.id, - index: 0, + entropy: { + type: KeyringAccountEntropyTypeOption.Mnemonic, + id: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: 0, + derivationPath: '', + }, }, methods: [...ETH_EOA_METHODS], type: EthAccountType.Eoa, @@ -97,9 +108,14 @@ export const MOCK_SNAP_ACCOUNT_1: InternalAccount = { id: 'mock-snap-id-1', address: 'aabbccdd', options: { - entropySource: MOCK_HD_KEYRING_2.metadata.id, - index: 0, - }, // Note: shares entropy with MOCK_HD_ACCOUNT_2 + entropy: { + type: KeyringAccountEntropyTypeOption.Mnemonic, + // NOTE: shares entropy with MOCK_HD_ACCOUNT_2 + id: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: 0, + derivationPath: '', + }, + }, methods: SOL_METHODS, type: SolAccountType.DataAccount, scopes: [SolScope.Mainnet], @@ -155,7 +171,7 @@ export class MockAccountBuilder { return new MockAccountBuilder(account); } - withUuuid() { + withUuid() { this.#account.id = uuid(); return this; } @@ -166,12 +182,20 @@ export class MockAccountBuilder { } withEntropySource(entropySource: EntropySourceId) { - this.#account.options.entropySource = entropySource; + if (!isBip44Account(this.#account)) { + throw new Error('Invalid BIP-44 account'); + } + + this.#account.options.entropy.id = entropySource; return this; } withGroupIndex(groupIndex: number) { - this.#account.options.index = groupIndex; + if (!isBip44Account(this.#account)) { + throw new Error('Invalid BIP-44 account'); + } + + this.#account.options.entropy.groupIndex = groupIndex; return this; } diff --git a/yarn.lock b/yarn.lock index bcaa81d25f2..ae81ed4b0ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2443,13 +2443,13 @@ __metadata: languageName: node linkType: hard -"@metamask/account-api@npm:^0.1.0": - version: 0.1.0 - resolution: "@metamask/account-api@npm:0.1.0" +"@metamask/account-api@npm:@metamask-previews/account-api@0.1.0-e86c8ec": + version: 0.1.0-e86c8ec + resolution: "@metamask-previews/account-api@npm:0.1.0-e86c8ec" dependencies: - "@metamask/keyring-api": "npm:^18.0.0" - "@metamask/keyring-utils": "npm:^3.0.0" - checksum: 10/6c7338c8551d39b96e3d04ed3a944ebb7ca48d7f9cb0fdf27c878e12311372d03a61123dc5e7ea21135c095920db7c52a79be4c81afd04a6d62e5c5306dd12cb + "@metamask/keyring-api": "npm:18.0.0" + "@metamask/keyring-utils": "npm:3.0.0" + checksum: 10/7f05c71ef71cf703063d525608e3788cc2953bfd8378309f0c4dc636a74910042bbc5dd67dca2680448020cfa1b29dd51f4710a858951f3bc4cf7d0c48f878f0 languageName: node linkType: hard @@ -3639,7 +3639,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:18.0.0, @metamask/keyring-api@npm:^18.0.0": +"@metamask/keyring-api@npm:18.0.0": version: 18.0.0 resolution: "@metamask/keyring-api@npm:18.0.0" dependencies: @@ -3651,7 +3651,19 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-controller@npm:^22.1.0, @metamask/keyring-controller@workspace:packages/keyring-controller": +"@metamask/keyring-api@npm:@metamask-previews/keyring-api@18.0.0-e86c8ec": + version: 18.0.0-e86c8ec + resolution: "@metamask-previews/keyring-api@npm:18.0.0-e86c8ec" + dependencies: + "@metamask/keyring-utils": "npm:3.0.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.1.0" + bitcoin-address-validation: "npm:^2.2.3" + checksum: 10/425022ebf8648cf25de3c5839fb76143ea578803ac58003b65ceded7dab8cc93f56ba8d454700b555ab12e5423b81c7b1d388b7437a4a88a89c776276109f913 + languageName: node + linkType: hard + +"@metamask/keyring-controller@npm:^22.0.2, @metamask/keyring-controller@npm:^22.1.0, @metamask/keyring-controller@workspace:packages/keyring-controller": version: 0.0.0-use.local resolution: "@metamask/keyring-controller@workspace:packages/keyring-controller" dependencies: @@ -3691,14 +3703,14 @@ __metadata: languageName: unknown linkType: soft -"@metamask/keyring-internal-api@npm:^6.2.0": - version: 6.2.0 - resolution: "@metamask/keyring-internal-api@npm:6.2.0" +"@metamask/keyring-internal-api@npm:@metamask-previews/keyring-internal-api@6.2.0-e86c8ec": + version: 6.2.0-e86c8ec + resolution: "@metamask-previews/keyring-internal-api@npm:6.2.0-e86c8ec" dependencies: - "@metamask/keyring-api": "npm:^18.0.0" - "@metamask/keyring-utils": "npm:^3.0.0" + "@metamask/keyring-api": "npm:18.0.0" + "@metamask/keyring-utils": "npm:3.0.0" "@metamask/superstruct": "npm:^3.1.0" - checksum: 10/81d1d91528ab422cc99d78204a23e21fbc96c9d3ae9ebb2589a28a897b2968f9c576af22f18c3e36c6731e518e2bd890272d5a1ce4f5e42ebbc589594a170dd8 + checksum: 10/f48efb208896392e9b383f9847ff56209613d729c8b2110b4d579182245073cb3e9e6c274d842990d2b407ccc1df41525d93ac658afc4e6b5e956f6c491f9550 languageName: node linkType: hard @@ -3731,7 +3743,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-utils@npm:3.0.0, @metamask/keyring-utils@npm:^3.0.0": +"@metamask/keyring-utils@npm:3.0.0": version: 3.0.0 resolution: "@metamask/keyring-utils@npm:3.0.0" dependencies: @@ -3743,6 +3755,18 @@ __metadata: languageName: node linkType: hard +"@metamask/keyring-utils@npm:@metamask-previews/keyring-utils@3.0.0-e86c8ec": + version: 3.0.0-e86c8ec + resolution: "@metamask-previews/keyring-utils@npm:3.0.0-e86c8ec" + dependencies: + "@ethereumjs/tx": "npm:^5.4.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.1.0" + bitcoin-address-validation: "npm:^2.2.3" + checksum: 10/7f4d636a4ddd13ed33604fe50aa07ed5ebffff271ace26f6939813f5275064297d9add9b2b9d2afbc50023c001c737dfb91f741712431a50f3a7c75f3b13f058 + languageName: node + linkType: hard + "@metamask/logging-controller@npm:^6.0.4, @metamask/logging-controller@workspace:packages/logging-controller": version: 0.0.0-use.local resolution: "@metamask/logging-controller@workspace:packages/logging-controller" @@ -3807,21 +3831,11 @@ __metadata: languageName: node linkType: hard -"@metamask/multichain-account-api@npm:@metamask-previews/multichain-account-api@0.0.0-5ab4699": - version: 0.0.0-5ab4699 - resolution: "@metamask-previews/multichain-account-api@npm:0.0.0-5ab4699" - dependencies: - "@metamask/keyring-api": "npm:18.0.0" - "@metamask/keyring-utils": "npm:3.0.0" - "@metamask/utils": "npm:^11.1.0" - checksum: 10/5a781f8e77f592ad1fb515e87f88c03f0d4fbf02f38f5de0e4c7996fb0db40c4fe3099e310bf13cee111d8eeeec7c15ef96e5c87343ba9ef513cc7db92b1952c - languageName: node - linkType: hard - "@metamask/multichain-account-controller@workspace:packages/multichain-account-controller": version: 0.0.0-use.local resolution: "@metamask/multichain-account-controller@workspace:packages/multichain-account-controller" dependencies: + "@metamask/account-api": "npm:^0.1.0" "@metamask/accounts-controller": "npm:^31.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" @@ -3830,11 +3844,11 @@ __metadata: "@metamask/keyring-controller": "npm:^22.0.2" "@metamask/keyring-internal-api": "npm:^6.2.0" "@metamask/keyring-snap-client": "npm:^5.0.0" - "@metamask/multichain-account-api": "npm:^0.0.0" "@metamask/providers": "npm:^22.1.0" - "@metamask/snaps-controllers": "npm:^12.3.1" - "@metamask/snaps-sdk": "npm:^7.1.0" - "@metamask/snaps-utils": "npm:^9.4.0" + "@metamask/snaps-controllers": "npm:^14.0.1" + "@metamask/snaps-sdk": "npm:^9.0.0" + "@metamask/snaps-utils": "npm:^11.0.0" + "@metamask/superstruct": "npm:^3.1.0" "@types/jest": "npm:^27.4.1" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" @@ -3848,9 +3862,8 @@ __metadata: peerDependencies: "@metamask/accounts-controller": ^31.0.0 "@metamask/keyring-controller": ^22.0.0 - "@metamask/multichain-account-api": ^0.0.0 "@metamask/providers": ^22.0.0 - "@metamask/snaps-controllers": ^12.0.0 + "@metamask/snaps-controllers": ^14.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 languageName: unknown linkType: soft From 4588bad5e1f5d2f498f90a9bed3e8f90ef8d3e48 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 17 Jul 2025 18:51:50 +0200 Subject: [PATCH 03/27] refactor: remove unused test code --- .../src/providers/EvmAccountProvider.test.ts | 77 +------------- .../src/providers/SolAccountProvider.test.ts | 100 +----------------- 2 files changed, 2 insertions(+), 175 deletions(-) diff --git a/packages/multichain-account-controller/src/providers/EvmAccountProvider.test.ts b/packages/multichain-account-controller/src/providers/EvmAccountProvider.test.ts index d0e50f2c8d7..b78d88c1d0e 100644 --- a/packages/multichain-account-controller/src/providers/EvmAccountProvider.test.ts +++ b/packages/multichain-account-controller/src/providers/EvmAccountProvider.test.ts @@ -1,9 +1,5 @@ import type { Messenger } from '@metamask/base-controller'; -import type { KeyringMetadata } from '@metamask/keyring-controller'; -import type { - EthKeyring, - InternalAccount, -} from '@metamask/keyring-internal-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; import { EvmAccountProvider } from './EvmAccountProvider'; import { @@ -11,7 +7,6 @@ import { getRootMessenger, MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2, - MockAccountBuilder, } from '../tests'; import type { AllowedActions, @@ -20,51 +15,6 @@ import type { MultichainAccountControllerEvents, } from '../types'; -class MockEthKeyring implements EthKeyring { - readonly type = 'MockEthKeyring'; - - readonly metadata: KeyringMetadata = { - id: 'mock-eth-keyring-id', - name: '', - }; - - readonly accounts: InternalAccount[]; - - constructor(accounts: InternalAccount[]) { - this.accounts = accounts; - } - - async serialize() { - return 'serialized'; - } - - async deserialize(_: string) { - // Not required. - } - - getAccounts = jest - .fn() - .mockImplementation(() => this.accounts.map((account) => account.address)); - - addAccounts = jest.fn().mockImplementation((numberOfAccounts: number) => { - const newAccountsIndex = this.accounts.length; - - // Just generate a new address by appending the number of accounts owned by that fake keyring. - for (let i = 0; i < numberOfAccounts; i++) { - this.accounts.push( - MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) - .withUuid() - .withAddressSuffix(`${this.accounts.length}`) - .get(), - ); - } - - return this.accounts - .slice(newAccountsIndex) - .map((account) => account.address); - }); -} - /** * Sets up a EvmAccountProvider for testing. * @@ -88,33 +38,12 @@ function setup({ MultichainAccountControllerActions | AllowedActions, MultichainAccountControllerEvents | AllowedEvents >; - keyring: MockEthKeyring; - mocks: { - getAccountsByAddress: jest.Mock; - }; } { - const keyring = new MockEthKeyring(accounts); - messenger.registerActionHandler( 'AccountsController:listMultichainAccounts', () => accounts, ); - const mockGetAccountByAddress = jest - .fn() - .mockImplementation((address: string) => - keyring.accounts.find((account) => account.address === address), - ); - messenger.registerActionHandler( - 'AccountsController:getAccountByAddress', - mockGetAccountByAddress, - ); - - messenger.registerActionHandler( - 'KeyringController:withKeyring', - async (_, operation) => operation({ keyring, metadata: keyring.metadata }), - ); - const provider = new EvmAccountProvider( getMultichainAccountControllerMessenger(messenger), ); @@ -122,10 +51,6 @@ function setup({ return { provider, messenger, - keyring, - mocks: { - getAccountsByAddress: mockGetAccountByAddress, - }, }; } diff --git a/packages/multichain-account-controller/src/providers/SolAccountProvider.test.ts b/packages/multichain-account-controller/src/providers/SolAccountProvider.test.ts index e5cc6126433..638fffc9303 100644 --- a/packages/multichain-account-controller/src/providers/SolAccountProvider.test.ts +++ b/packages/multichain-account-controller/src/providers/SolAccountProvider.test.ts @@ -1,10 +1,5 @@ import type { Messenger } from '@metamask/base-controller'; -import type { SnapKeyring } from '@metamask/eth-snap-keyring'; -import type { KeyringMetadata } from '@metamask/keyring-controller'; -import type { - EthKeyring, - InternalAccount, -} from '@metamask/keyring-internal-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; import { SolAccountProvider } from './SolAccountProvider'; import { @@ -12,7 +7,6 @@ import { getRootMessenger, MOCK_HD_ACCOUNT_1, MOCK_SNAP_ACCOUNT_1, - MockAccountBuilder, } from '../tests'; import type { AllowedActions, @@ -21,61 +15,6 @@ import type { MultichainAccountControllerEvents, } from '../types'; -class MockSolanaKeyring { - readonly type = 'MockSolanaKeyring'; - - readonly metadata: KeyringMetadata = { - id: 'mock-solana-keyring-id', - name: '', - }; - - readonly accounts: InternalAccount[]; - - constructor(accounts: InternalAccount[]) { - this.accounts = accounts; - } - - #getIndexFromDerivationPath(derivationPath: string): number { - // eslint-disable-next-line prefer-regex-literals - const derivationPathIndexRegex = new RegExp( - "m/44'/501'/(?[0-9]+)'/0", - 'u', - ); - - const matched = derivationPath.match(derivationPathIndexRegex); - if (matched?.groups?.index === undefined) { - throw new Error('Unable to extract index'); - } - - const { index } = matched.groups; - return Number(index); - } - - createAccount: SnapKeyring['createAccount'] = jest - .fn() - .mockImplementation((_, options) => { - if (options.derivationPath !== undefined) { - const index = this.#getIndexFromDerivationPath(options.derivationPath); - const found = this.accounts.find( - (account) => account.options.index === index, - ); - - if (found) { - return found; // Idempotent. - } - } - - const account = MockAccountBuilder.from(MOCK_SNAP_ACCOUNT_1) - .withUuid() - .withAddressSuffix(`${this.accounts.length}`) - .withGroupIndex(this.accounts.length) - .get(); - this.accounts.push(account); - - return account; - }); -} - /** * Sets up a SolAccountProvider for testing. * @@ -99,42 +38,12 @@ function setup({ MultichainAccountControllerActions | AllowedActions, MultichainAccountControllerEvents | AllowedEvents >; - keyring: MockSolanaKeyring; - mocks: { - handleRequest: jest.Mock; - keyring: { - createAccount: jest.Mock; - }; - }; } { - const keyring = new MockSolanaKeyring(accounts); - messenger.registerActionHandler( 'AccountsController:listMultichainAccounts', () => accounts, ); - const mockHandleRequest = jest - .fn() - .mockImplementation((address: string) => - keyring.accounts.find((account) => account.address === address), - ); - messenger.registerActionHandler( - 'SnapController:handleRequest', - mockHandleRequest, - ); - - messenger.registerActionHandler( - 'KeyringController:withKeyring', - async (_, operation) => - operation({ - // We type-cast here, since `withKeyring` defaults to `EthKeyring` and the - // Snap keyring does really implement this interface (this is expected). - keyring: keyring as unknown as EthKeyring, - metadata: keyring.metadata, - }), - ); - const provider = new SolAccountProvider( getMultichainAccountControllerMessenger(messenger), ); @@ -142,13 +51,6 @@ function setup({ return { provider, messenger, - keyring, - mocks: { - handleRequest: mockHandleRequest, - keyring: { - createAccount: keyring.createAccount as jest.Mock, - }, - }, }; } From 7c23474516bbceafb3fb1ff0cd1a6915cf2cc25c Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 17 Jul 2025 18:51:56 +0200 Subject: [PATCH 04/27] chore: update README --- packages/multichain-account-controller/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/multichain-account-controller/README.md b/packages/multichain-account-controller/README.md index 12ba493251d..a112d50bb6d 100644 --- a/packages/multichain-account-controller/README.md +++ b/packages/multichain-account-controller/README.md @@ -1,6 +1,8 @@ # `@metamask/multichain-account-controller` -example +Multichain account stateless controller. + +This controller provides operations and functionalities around multichain accounts and wallets. ## Installation From 8e81e6a34aaaba96f292f8904983fc79f0a8f1c2 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 17 Jul 2025 18:58:54 +0200 Subject: [PATCH 05/27] chore: update CODEOWNERS --- .github/CODEOWNERS | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4af50ad4785..50210a39fff 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,9 +7,10 @@ /.github/ @MetaMask/core-platform ## Accounts Team -/packages/accounts-controller @MetaMask/accounts-engineers +/packages/accounts-controller @MetaMask/accounts-engineers /packages/multichain-transactions-controller @MetaMask/accounts-engineers -/packages/account-tree-controller @MetaMask/accounts-engineers +/packages/multichain-account-controller @MetaMask/accounts-engineers +/packages/account-tree-controller @MetaMask/accounts-engineers ## Assets Team /packages/assets-controllers @MetaMask/metamask-assets From ed8e17c7355c72c6dff084e81a04393e87863c4b Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 17 Jul 2025 18:59:30 +0200 Subject: [PATCH 06/27] chore: fix deps --- packages/multichain-account-controller/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/multichain-account-controller/package.json b/packages/multichain-account-controller/package.json index 8798ee14310..18e14f30260 100644 --- a/packages/multichain-account-controller/package.json +++ b/packages/multichain-account-controller/package.json @@ -50,7 +50,6 @@ "@metamask/account-api": "^0.1.0", "@metamask/base-controller": "^8.0.1", "@metamask/keyring-api": "^18.0.0", - "@metamask/keyring-controller": "^19.0.0", "@metamask/keyring-internal-api": "^6.2.0", "@metamask/keyring-snap-client": "^5.0.0", "@metamask/snaps-sdk": "^9.0.0", @@ -61,7 +60,7 @@ "@metamask/accounts-controller": "^31.0.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-snap-keyring": "^13.0.0", - "@metamask/keyring-controller": "^22.0.2", + "@metamask/keyring-controller": "^22.1.0", "@metamask/providers": "^22.1.0", "@metamask/snaps-controllers": "^14.0.1", "@types/jest": "^27.4.1", From 0c7ec8a72d7fd78e4236813fbc6e34dd4121102e Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 10:52:22 +0200 Subject: [PATCH 07/27] chore: remove use of preview builds --- package.json | 7 +- .../package.json | 8 +- yarn.lock | 87 ++++++------------- 3 files changed, 33 insertions(+), 69 deletions(-) diff --git a/package.json b/package.json index 021760b4315..d1de49a4315 100644 --- a/package.json +++ b/package.json @@ -46,12 +46,7 @@ }, "resolutions": { "elliptic@6.5.4": "^6.5.7", - "fast-xml-parser@^4.3.4": "^4.4.1", - "ws@7.4.6": "^7.5.10", - "@metamask/keyring-utils@^3.0.0": "npm:@metamask-previews/keyring-utils@3.0.0-e86c8ec", - "@metamask/keyring-api@^18.0.0": "npm:@metamask-previews/keyring-api@18.0.0-e86c8ec", - "@metamask/keyring-internal-api@^6.2.0": "npm:@metamask-previews/keyring-internal-api@6.2.0-e86c8ec", - "@metamask/account-api@^0.1.0": "npm:@metamask-previews/account-api@0.1.0-e86c8ec" + "fast-xml-parser@^4.3.4": "^4.4.1" }, "devDependencies": { "@babel/core": "^7.23.5", diff --git a/packages/multichain-account-controller/package.json b/packages/multichain-account-controller/package.json index 18e14f30260..d7b0e50b8ba 100644 --- a/packages/multichain-account-controller/package.json +++ b/packages/multichain-account-controller/package.json @@ -47,11 +47,11 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/account-api": "^0.1.0", + "@metamask/account-api": "^0.2.0", "@metamask/base-controller": "^8.0.1", - "@metamask/keyring-api": "^18.0.0", - "@metamask/keyring-internal-api": "^6.2.0", - "@metamask/keyring-snap-client": "^5.0.0", + "@metamask/keyring-api": "^19.0.0", + "@metamask/keyring-internal-api": "^7.0.0", + "@metamask/keyring-snap-client": "^6.0.0", "@metamask/snaps-sdk": "^9.0.0", "@metamask/snaps-utils": "^11.0.0", "@metamask/superstruct": "^3.1.0" diff --git a/yarn.lock b/yarn.lock index 3f8b268640e..26f61769a37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2443,16 +2443,6 @@ __metadata: languageName: node linkType: hard -"@metamask/account-api@npm:@metamask-previews/account-api@0.1.0-e86c8ec": - version: 0.1.0-e86c8ec - resolution: "@metamask-previews/account-api@npm:0.1.0-e86c8ec" - dependencies: - "@metamask/keyring-api": "npm:18.0.0" - "@metamask/keyring-utils": "npm:3.0.0" - checksum: 10/7f05c71ef71cf703063d525608e3788cc2953bfd8378309f0c4dc636a74910042bbc5dd67dca2680448020cfa1b29dd51f4710a858951f3bc4cf7d0c48f878f0 - languageName: node - linkType: hard - "@metamask/account-api@npm:^0.2.0": version: 0.2.0 resolution: "@metamask/account-api@npm:0.2.0" @@ -3670,7 +3660,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:18.0.0": +"@metamask/keyring-api@npm:^18.0.0": version: 18.0.0 resolution: "@metamask/keyring-api@npm:18.0.0" dependencies: @@ -3682,18 +3672,6 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:@metamask-previews/keyring-api@18.0.0-e86c8ec": - version: 18.0.0-e86c8ec - resolution: "@metamask-previews/keyring-api@npm:18.0.0-e86c8ec" - dependencies: - "@metamask/keyring-utils": "npm:3.0.0" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.1.0" - bitcoin-address-validation: "npm:^2.2.3" - checksum: 10/425022ebf8648cf25de3c5839fb76143ea578803ac58003b65ceded7dab8cc93f56ba8d454700b555ab12e5423b81c7b1d388b7437a4a88a89c776276109f913 - languageName: node - linkType: hard - "@metamask/keyring-api@npm:^19.0.0": version: 19.0.0 resolution: "@metamask/keyring-api@npm:19.0.0" @@ -3746,14 +3724,14 @@ __metadata: languageName: unknown linkType: soft -"@metamask/keyring-internal-api@npm:@metamask-previews/keyring-internal-api@6.2.0-e86c8ec": - version: 6.2.0-e86c8ec - resolution: "@metamask-previews/keyring-internal-api@npm:6.2.0-e86c8ec" +"@metamask/keyring-internal-api@npm:^6.2.0": + version: 6.2.0 + resolution: "@metamask/keyring-internal-api@npm:6.2.0" dependencies: - "@metamask/keyring-api": "npm:18.0.0" - "@metamask/keyring-utils": "npm:3.0.0" + "@metamask/keyring-api": "npm:^18.0.0" + "@metamask/keyring-utils": "npm:^3.0.0" "@metamask/superstruct": "npm:^3.1.0" - checksum: 10/f48efb208896392e9b383f9847ff56209613d729c8b2110b4d579182245073cb3e9e6c274d842990d2b407ccc1df41525d93ac658afc4e6b5e956f6c491f9550 + checksum: 10/81d1d91528ab422cc99d78204a23e21fbc96c9d3ae9ebb2589a28a897b2968f9c576af22f18c3e36c6731e518e2bd890272d5a1ce4f5e42ebbc589594a170dd8 languageName: node linkType: hard @@ -3826,31 +3804,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-utils@npm:3.0.0": - version: 3.0.0 - resolution: "@metamask/keyring-utils@npm:3.0.0" - dependencies: - "@ethereumjs/tx": "npm:^5.4.0" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.1.0" - bitcoin-address-validation: "npm:^2.2.3" - checksum: 10/eff3c0b9a86d6a25c5dd443946ba3ff56cb94fcb915a4eb061089819805e1e78eba2ea5cfb12a47ec4606542870c417de422f755947389ab9f3a4f08e96742db - languageName: node - linkType: hard - -"@metamask/keyring-utils@npm:@metamask-previews/keyring-utils@3.0.0-e86c8ec": - version: 3.0.0-e86c8ec - resolution: "@metamask-previews/keyring-utils@npm:3.0.0-e86c8ec" - dependencies: - "@ethereumjs/tx": "npm:^5.4.0" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.1.0" - bitcoin-address-validation: "npm:^2.2.3" - checksum: 10/7f4d636a4ddd13ed33604fe50aa07ed5ebffff271ace26f6939813f5275064297d9add9b2b9d2afbc50023c001c737dfb91f741712431a50f3a7c75f3b13f058 - languageName: node - linkType: hard - -"@metamask/keyring-utils@npm:^3.1.0": +"@metamask/keyring-utils@npm:^3.0.0, @metamask/keyring-utils@npm:^3.1.0": version: 3.1.0 resolution: "@metamask/keyring-utils@npm:3.1.0" dependencies: @@ -3930,15 +3884,15 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/multichain-account-controller@workspace:packages/multichain-account-controller" dependencies: - "@metamask/account-api": "npm:^0.1.0" + "@metamask/account-api": "npm:^0.2.0" "@metamask/accounts-controller": "npm:^31.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" "@metamask/eth-snap-keyring": "npm:^13.0.0" - "@metamask/keyring-api": "npm:^18.0.0" + "@metamask/keyring-api": "npm:^19.0.0" "@metamask/keyring-controller": "npm:^22.1.0" - "@metamask/keyring-internal-api": "npm:^6.2.0" - "@metamask/keyring-snap-client": "npm:^5.0.0" + "@metamask/keyring-internal-api": "npm:^7.0.0" + "@metamask/keyring-snap-client": "npm:^6.0.0" "@metamask/providers": "npm:^22.1.0" "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/snaps-sdk": "npm:^9.0.0" @@ -15124,6 +15078,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:7.4.6": + version: 7.4.6 + resolution: "ws@npm:7.4.6" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/150e3f917b7cde568d833a5ea6ccc4132e59c38d04218afcf2b6c7b845752bd011a9e0dc1303c8694d3c402a0bdec5893661a390b71ff88f0fc81a4e4e66b09c + languageName: node + linkType: hard + "ws@npm:8.17.1": version: 8.17.1 resolution: "ws@npm:8.17.1" @@ -15139,7 +15108,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^7, ws@npm:^7.4.6, ws@npm:^7.5.10, ws@npm:^7.5.5": +"ws@npm:^7, ws@npm:^7.4.6, ws@npm:^7.5.5": version: 7.5.10 resolution: "ws@npm:7.5.10" peerDependencies: From e29e34424d5246d8d7b173ddb2038e1e3bbe8781 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 10:57:52 +0200 Subject: [PATCH 08/27] chore: bump eth-snap-keyring (was missing) --- .../package.json | 2 +- yarn.lock | 77 +------------------ 2 files changed, 3 insertions(+), 76 deletions(-) diff --git a/packages/multichain-account-controller/package.json b/packages/multichain-account-controller/package.json index d7b0e50b8ba..92d9177e98d 100644 --- a/packages/multichain-account-controller/package.json +++ b/packages/multichain-account-controller/package.json @@ -59,7 +59,7 @@ "devDependencies": { "@metamask/accounts-controller": "^31.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/eth-snap-keyring": "^13.0.0", + "@metamask/eth-snap-keyring": "^14.0.0", "@metamask/keyring-controller": "^22.1.0", "@metamask/providers": "^22.1.0", "@metamask/snaps-controllers": "^14.0.1", diff --git a/yarn.lock b/yarn.lock index 26f61769a37..9ee7e2c31f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3357,27 +3357,6 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-snap-keyring@npm:^13.0.0": - version: 13.0.0 - resolution: "@metamask/eth-snap-keyring@npm:13.0.0" - dependencies: - "@ethereumjs/tx": "npm:^5.4.0" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/keyring-api": "npm:^18.0.0" - "@metamask/keyring-internal-api": "npm:^6.2.0" - "@metamask/keyring-internal-snap-client": "npm:^4.1.0" - "@metamask/keyring-utils": "npm:^3.0.0" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.1.0" - "@types/uuid": "npm:^9.0.8" - uuid: "npm:^9.0.1" - peerDependencies: - "@metamask/keyring-api": ^18.0.0 - checksum: 10/905d39e05a5b4aba101b8c0dedfda68b0607a010007d6a9597ddb462d09cce4019d4b24880e8803210c38ce3245bccd80f790bf0849cc62691a504ce03930986 - languageName: node - linkType: hard - "@metamask/eth-snap-keyring@npm:^14.0.0": version: 14.0.0 resolution: "@metamask/eth-snap-keyring@npm:14.0.0" @@ -3660,18 +3639,6 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:^18.0.0": - version: 18.0.0 - resolution: "@metamask/keyring-api@npm:18.0.0" - dependencies: - "@metamask/keyring-utils": "npm:^3.0.0" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.1.0" - bitcoin-address-validation: "npm:^2.2.3" - checksum: 10/11b4680399e9c3677637084b87d0da755bf3ceb35a060e7b4e8e697489d4ef117d97d80df6d9ca9fb75ee61f9cd225bc901028f6e43775e1ee683e4369ed4fdb - languageName: node - linkType: hard - "@metamask/keyring-api@npm:^19.0.0": version: 19.0.0 resolution: "@metamask/keyring-api@npm:19.0.0" @@ -3724,17 +3691,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/keyring-internal-api@npm:^6.2.0": - version: 6.2.0 - resolution: "@metamask/keyring-internal-api@npm:6.2.0" - dependencies: - "@metamask/keyring-api": "npm:^18.0.0" - "@metamask/keyring-utils": "npm:^3.0.0" - "@metamask/superstruct": "npm:^3.1.0" - checksum: 10/81d1d91528ab422cc99d78204a23e21fbc96c9d3ae9ebb2589a28a897b2968f9c576af22f18c3e36c6731e518e2bd890272d5a1ce4f5e42ebbc589594a170dd8 - languageName: node - linkType: hard - "@metamask/keyring-internal-api@npm:^7.0.0": version: 7.0.0 resolution: "@metamask/keyring-internal-api@npm:7.0.0" @@ -3746,19 +3702,6 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-internal-snap-client@npm:^4.1.0": - version: 4.1.0 - resolution: "@metamask/keyring-internal-snap-client@npm:4.1.0" - dependencies: - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/keyring-api": "npm:^18.0.0" - "@metamask/keyring-internal-api": "npm:^6.2.0" - "@metamask/keyring-snap-client": "npm:^5.0.0" - "@metamask/keyring-utils": "npm:^3.0.0" - checksum: 10/7e536df7733b5d00558e009832326be6d56367f330fef7f3b073ecca8e184176f1353a2635a2e13a6128d6cfdc972ce6389307d41c7bc6b8403f7dcac30f92fe - languageName: node - linkType: hard - "@metamask/keyring-internal-snap-client@npm:^5.0.0": version: 5.0.0 resolution: "@metamask/keyring-internal-snap-client@npm:5.0.0" @@ -3772,22 +3715,6 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-snap-client@npm:^5.0.0": - version: 5.0.0 - resolution: "@metamask/keyring-snap-client@npm:5.0.0" - dependencies: - "@metamask/keyring-api": "npm:^18.0.0" - "@metamask/keyring-utils": "npm:^3.0.0" - "@metamask/superstruct": "npm:^3.1.0" - "@types/uuid": "npm:^9.0.8" - uuid: "npm:^9.0.1" - webextension-polyfill: "npm:^0.12.0" - peerDependencies: - "@metamask/providers": ^19.0.0 - checksum: 10/679f5285cd1e3c7617081ba207680c1eb49e8a18eaf72472f07e02829adcbe46ad8ed1bef2bb32de08e5a0b996beb9436914246cc92c253dc41aa348a6c32612 - languageName: node - linkType: hard - "@metamask/keyring-snap-client@npm:^6.0.0": version: 6.0.0 resolution: "@metamask/keyring-snap-client@npm:6.0.0" @@ -3804,7 +3731,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-utils@npm:^3.0.0, @metamask/keyring-utils@npm:^3.1.0": +"@metamask/keyring-utils@npm:^3.1.0": version: 3.1.0 resolution: "@metamask/keyring-utils@npm:3.1.0" dependencies: @@ -3888,7 +3815,7 @@ __metadata: "@metamask/accounts-controller": "npm:^31.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/eth-snap-keyring": "npm:^13.0.0" + "@metamask/eth-snap-keyring": "npm:^14.0.0" "@metamask/keyring-api": "npm:^19.0.0" "@metamask/keyring-controller": "npm:^22.1.0" "@metamask/keyring-internal-api": "npm:^7.0.0" From 1edf2eb3bb43502100dc0f4670e91238ad80c449 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 10:59:57 +0200 Subject: [PATCH 09/27] chore: update CODEOWNERS --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 50210a39fff..c7c4696402f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -119,6 +119,8 @@ /packages/logging-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform /packages/message-manager/package.json @MetaMask/confirmations @MetaMask/core-platform /packages/message-manager/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/multichain-account-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/multichain-account-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform /packages/multichain-api-middleware/package.json @MetaMask/wallet-api-platform-engineers @MetaMask/core-platform /packages/multichain-api-middleware/CHANGELOG.md @MetaMask/wallet-api-platform-engineers @MetaMask/core-platform /packages/name-controller/package.json @MetaMask/confirmations @MetaMask/core-platform From 51f44049d264cb8780882d2450d6debba53a35b2 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 11:11:59 +0200 Subject: [PATCH 10/27] chore: restore previous resolution --- package.json | 3 ++- yarn.lock | 17 +---------------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index d1de49a4315..dee348c4ae7 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ }, "resolutions": { "elliptic@6.5.4": "^6.5.7", - "fast-xml-parser@^4.3.4": "^4.4.1" + "fast-xml-parser@^4.3.4": "^4.4.1", + "ws@7.4.6": "^7.5.10" }, "devDependencies": { "@babel/core": "^7.23.5", diff --git a/yarn.lock b/yarn.lock index 9ee7e2c31f9..c24588cb7ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15005,21 +15005,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:7.4.6": - version: 7.4.6 - resolution: "ws@npm:7.4.6" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10/150e3f917b7cde568d833a5ea6ccc4132e59c38d04218afcf2b6c7b845752bd011a9e0dc1303c8694d3c402a0bdec5893661a390b71ff88f0fc81a4e4e66b09c - languageName: node - linkType: hard - "ws@npm:8.17.1": version: 8.17.1 resolution: "ws@npm:8.17.1" @@ -15035,7 +15020,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^7, ws@npm:^7.4.6, ws@npm:^7.5.5": +"ws@npm:^7, ws@npm:^7.4.6, ws@npm:^7.5.10, ws@npm:^7.5.5": version: 7.5.10 resolution: "ws@npm:7.5.10" peerDependencies: From d9d8f5dfcb4a9e3de3ab8ff0f4ad43d9ed6cfd27 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 11:36:27 +0200 Subject: [PATCH 11/27] chore: update teams.json --- teams.json | 1 + 1 file changed, 1 insertion(+) diff --git a/teams.json b/teams.json index 7d805a7a364..3f8031ec360 100644 --- a/teams.json +++ b/teams.json @@ -24,6 +24,7 @@ "metamask/logging-controller": "team-confirmations", "metamask/message-manager": "team-confirmations", "metamask/messenger": "team-wallet-framework", + "metamask/multichain-account-controller": "team-accounts", "metamask/multichain-api-middleware": "team-wallet-api-platform", "metamask/multichain-network-controller": "team-wallet-api-platform", "metamask/name-controller": "team-confirmations", From 4c9532dc668ba498ab89a7337abe56ea028b1196 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 14:48:19 +0200 Subject: [PATCH 12/27] chore: typo Co-authored-by: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> --- .../src/MultichainAccountController.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/multichain-account-controller/src/MultichainAccountController.ts b/packages/multichain-account-controller/src/MultichainAccountController.ts index 7afb79c90f7..b5cf2146e8d 100644 --- a/packages/multichain-account-controller/src/MultichainAccountController.ts +++ b/packages/multichain-account-controller/src/MultichainAccountController.ts @@ -84,7 +84,8 @@ export class MultichainAccountController { if (!wallet) { throw new Error( - 'Unknown wallet, not wallet matching this entropy source', + 'Unknown wallet, no wallet matching this entropy source', + ); } From de60d19f6127a8320caffecca9facfec2ed41117 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 14:48:07 +0200 Subject: [PATCH 13/27] chore: add more comment --- .../src/providers/BaseAccountProvider.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/multichain-account-controller/src/providers/BaseAccountProvider.ts b/packages/multichain-account-controller/src/providers/BaseAccountProvider.ts index f471cef1247..fa362a63775 100644 --- a/packages/multichain-account-controller/src/providers/BaseAccountProvider.ts +++ b/packages/multichain-account-controller/src/providers/BaseAccountProvider.ts @@ -52,6 +52,9 @@ export abstract class BaseAccountProvider const accounts: Bip44Account[] = []; for (const account of this.messenger.call( + // NOTE: Even though the name is misleading, this only fetches all internal + // accounts, including EVM and non-EVM. We might wanna change this action + // name once we fully support multichain accounts. 'AccountsController:listMultichainAccounts', )) { if ( From d452337ca4ba378be5750e204bfcb108d8bd16ab Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 14:54:54 +0200 Subject: [PATCH 14/27] chore: add missing jsdocs --- .../src/MultichainAccountController.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/multichain-account-controller/src/MultichainAccountController.ts b/packages/multichain-account-controller/src/MultichainAccountController.ts index b5cf2146e8d..9217073a3ae 100644 --- a/packages/multichain-account-controller/src/MultichainAccountController.ts +++ b/packages/multichain-account-controller/src/MultichainAccountController.ts @@ -52,6 +52,10 @@ export class MultichainAccountController { ]; } + /** + * Initialize the controller and constructs the internal reprensentation of + * multichain accounts and wallets. + */ init(): void { // Gather all entropy sources first. const { keyrings } = this.#messenger.call('KeyringController:getState'); @@ -92,6 +96,15 @@ export class MultichainAccountController { return wallet; } + /** + * Gets a reference to the multichain account matching this entropy source and group index. + * + * @param options - Options. + * @param options.entropySource - The entropy source of the multichain account. + * @param options.groupIndex - The group index of the multichain account. + * @throws If none multichain account match the entropy source and group index. + * @returns A reference to the multichain account. + */ getMultichainAccount({ entropySource, groupIndex, @@ -109,6 +122,13 @@ export class MultichainAccountController { return multichainAccount; } + /** + * Gets all multichain accounts for a given entropy source. + * + * @param options - Options. + * @param options.entropySource - The entropy source to query. + * @returns A list of all multichain accounts. + */ getMultichainAccounts({ entropySource, }: { From 0891e58e9489e0ec552ca8901fc02f13981b5277 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 14:55:10 +0200 Subject: [PATCH 15/27] feat: handle keyring state change for new entropy sources --- .../src/MultichainAccountController.test.ts | 53 +++++++++++++++++++ .../src/MultichainAccountController.ts | 40 +++++++++----- .../src/tests/messenger.ts | 2 +- .../src/types.ts | 3 +- 4 files changed, 83 insertions(+), 15 deletions(-) diff --git a/packages/multichain-account-controller/src/MultichainAccountController.test.ts b/packages/multichain-account-controller/src/MultichainAccountController.test.ts index 7a5c64c2dc7..c2afd3631ed 100644 --- a/packages/multichain-account-controller/src/MultichainAccountController.test.ts +++ b/packages/multichain-account-controller/src/MultichainAccountController.test.ts @@ -278,4 +278,57 @@ describe('MultichainAccountController', () => { ).toThrow(`No multichain account for index: ${groupIndex}`); }); }); + + describe('on KeyringController:stateChange', () => { + it('re-sets the internal wallets if a new entropy source is being added', () => { + const keyrings = [MOCK_HD_KEYRING_1]; + const accounts = [ + // Wallet 1: + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(), + ]; + const { controller, messenger, mocks } = setup({ + keyrings, + accounts, + }); + + // This wallet does not exist yet. + expect(() => + controller.getMultichainAccounts({ + entropySource: MOCK_HD_KEYRING_2.metadata.id, + }), + ).toThrow('Unknown wallet, not wallet matching this entropy source'); + + // Simulate new keyring being added. + keyrings.push(MOCK_HD_KEYRING_2); + // NOTE: We also need to update the account list now, since accounts + // are being used as soon as we construct the multichain account + // wallet. + accounts.push( + // Wallet 2: + MockAccountBuilder.from(MOCK_HD_ACCOUNT_2) + .withEntropySource(MOCK_HD_KEYRING_2.metadata.id) + .withGroupIndex(0) + .get(), + ); + mocks.EvmAccountProvider.getAccounts.mockImplementation(() => accounts); + messenger.publish( + 'KeyringController:stateChange', + { + isUnlocked: true, + keyrings, + }, + [], + ); + + // We should now be able to query that wallet. + expect( + controller.getMultichainAccounts({ + entropySource: MOCK_HD_KEYRING_2.metadata.id, + }), + ).toHaveLength(1); + }); + }); }); diff --git a/packages/multichain-account-controller/src/MultichainAccountController.ts b/packages/multichain-account-controller/src/MultichainAccountController.ts index 9217073a3ae..b628237e195 100644 --- a/packages/multichain-account-controller/src/MultichainAccountController.ts +++ b/packages/multichain-account-controller/src/MultichainAccountController.ts @@ -8,6 +8,7 @@ import { type MultichainAccount, } from '@metamask/account-api'; import type { EntropySourceId } from '@metamask/keyring-api'; +import type { KeyringObject } from '@metamask/keyring-controller'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; @@ -59,24 +60,37 @@ export class MultichainAccountController { init(): void { // Gather all entropy sources first. const { keyrings } = this.#messenger.call('KeyringController:getState'); + this.#setMultichainAccountWallets(keyrings); + + // TODO: For now, we to every `KeyringController` state change to detect when + // new entropy sources/SRPs are being added. Having a dedicated event when + // new keyrings are added would make this more efficient. + this.#messenger.subscribe('KeyringController:stateChange', (state) => { + this.#setMultichainAccountWallets(state.keyrings); + }); + } - const entropySources = []; + #setMultichainAccountWallets(keyrings: KeyringObject[]) { for (const keyring of keyrings) { if (keyring.type === (KeyringTypes.hd as string)) { - entropySources.push(keyring.metadata.id); + // Only HD keyrings have an entropy source/SRP. + const entropySource = keyring.metadata.id; + + // Do not re-create wallets if they exists. Even if a keyrings got new accounts, this + // will be handled by the `*AccountProvider`s which are always in-sync with their + // keyrings and controllers (like the `AccountsController`). + if (!this.#wallets.has(toMultichainAccountWalletId(entropySource))) { + // This will automatically "associate" all multichain accounts for that wallet + // (based on the accounts owned by each account providers). + const wallet = new MultichainAccountWallet({ + entropySource, + providers: this.#providers, + }); + + this.#wallets.set(wallet.id, wallet); + } } } - - for (const entropySource of entropySources) { - // This will automatically create all multichain accounts for that wallet (based - // on the accounts owned by each account providers). - const wallet = new MultichainAccountWallet({ - entropySource, - providers: this.#providers, - }); - - this.#wallets.set(wallet.id, wallet); - } } #getWallet( diff --git a/packages/multichain-account-controller/src/tests/messenger.ts b/packages/multichain-account-controller/src/tests/messenger.ts index 4e5f1045308..a9a7768d4d4 100644 --- a/packages/multichain-account-controller/src/tests/messenger.ts +++ b/packages/multichain-account-controller/src/tests/messenger.ts @@ -31,7 +31,7 @@ export function getMultichainAccountControllerMessenger( ): MultichainAccountControllerMessenger { return messenger.getRestricted({ name: 'MultichainAccountController', - allowedEvents: [], + allowedEvents: ['KeyringController:stateChange'], allowedActions: [ 'AccountsController:getAccount', 'AccountsController:getAccountByAddress', diff --git a/packages/multichain-account-controller/src/types.ts b/packages/multichain-account-controller/src/types.ts index b37e56d69ed..07986646ae7 100644 --- a/packages/multichain-account-controller/src/types.ts +++ b/packages/multichain-account-controller/src/types.ts @@ -6,6 +6,7 @@ import type { import type { RestrictedMessenger } from '@metamask/base-controller'; import type { KeyringControllerGetStateAction, + KeyringControllerStateChangeEvent, KeyringControllerWithKeyringAction, } from '@metamask/keyring-controller'; import type { HandleSnapRequest as SnapControllerHandleSnapRequestAction } from '@metamask/snaps-controllers'; @@ -37,7 +38,7 @@ export type AllowedActions = * All events published by other modules that {@link MultichainAccountController} * subscribes to. */ -export type AllowedEvents = never; +export type AllowedEvents = KeyringControllerStateChangeEvent; /** * The messenger restricted to actions and events that From 7e3c5a83dab5d1ebfa56bd33a811816f25e39051 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 15:24:54 +0200 Subject: [PATCH 16/27] fix: fix jsdocs --- .../src/MultichainAccountController.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/multichain-account-controller/src/MultichainAccountController.ts b/packages/multichain-account-controller/src/MultichainAccountController.ts index b628237e195..540afcc89df 100644 --- a/packages/multichain-account-controller/src/MultichainAccountController.ts +++ b/packages/multichain-account-controller/src/MultichainAccountController.ts @@ -116,7 +116,7 @@ export class MultichainAccountController { * @param options - Options. * @param options.entropySource - The entropy source of the multichain account. * @param options.groupIndex - The group index of the multichain account. - * @throws If none multichain account match the entropy source and group index. + * @throws If none multichain account match this entropy source and group index. * @returns A reference to the multichain account. */ getMultichainAccount({ @@ -141,6 +141,7 @@ export class MultichainAccountController { * * @param options - Options. * @param options.entropySource - The entropy source to query. + * @throws If no multichain accounts match this entropy source. * @returns A list of all multichain accounts. */ getMultichainAccounts({ From 349826f14c12c768659066495dd704ebbdbe4d3f Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 15:26:35 +0200 Subject: [PATCH 17/27] test: fix error messages --- .../src/MultichainAccountController.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/multichain-account-controller/src/MultichainAccountController.test.ts b/packages/multichain-account-controller/src/MultichainAccountController.test.ts index c2afd3631ed..b4876ccfd00 100644 --- a/packages/multichain-account-controller/src/MultichainAccountController.test.ts +++ b/packages/multichain-account-controller/src/MultichainAccountController.test.ts @@ -225,7 +225,7 @@ describe('MultichainAccountController', () => { controller.getMultichainAccounts({ entropySource: MOCK_HD_KEYRING_2.metadata.id, }), - ).toThrow('Unknown wallet, not wallet matching this entropy source'); + ).toThrow('Unknown wallet, no wallet matching this entropy source'); }); }); @@ -299,7 +299,7 @@ describe('MultichainAccountController', () => { controller.getMultichainAccounts({ entropySource: MOCK_HD_KEYRING_2.metadata.id, }), - ).toThrow('Unknown wallet, not wallet matching this entropy source'); + ).toThrow('Unknown wallet, no wallet matching this entropy source'); // Simulate new keyring being added. keyrings.push(MOCK_HD_KEYRING_2); From ed97c85d9e5b099ef2b8e41b969ce7340899d12d Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 15:30:08 +0200 Subject: [PATCH 18/27] build: update tsconfig --- packages/multichain-account-controller/tsconfig.build.json | 3 ++- packages/multichain-account-controller/tsconfig.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/multichain-account-controller/tsconfig.build.json b/packages/multichain-account-controller/tsconfig.build.json index 1f4e9685f83..c01fbe218d1 100644 --- a/packages/multichain-account-controller/tsconfig.build.json +++ b/packages/multichain-account-controller/tsconfig.build.json @@ -7,7 +7,8 @@ }, "references": [ { "path": "../base-controller/tsconfig.build.json" }, - { "path": "../account-tree-controller/tsconfig.build.json" } + { "path": "../accounts-controller/tsconfig.build.json" }, + { "path": "../keyring-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/multichain-account-controller/tsconfig.json b/packages/multichain-account-controller/tsconfig.json index 875330b9270..c67da70b6eb 100644 --- a/packages/multichain-account-controller/tsconfig.json +++ b/packages/multichain-account-controller/tsconfig.json @@ -5,7 +5,8 @@ }, "references": [ { "path": "../base-controller" }, - { "path": "../accounts-controller" } + { "path": "../accounts-controller" }, + { "path": "../keyring-controller" } ], "include": ["../../types", "./src"] } From 5efb545c71a749831dfd847138c2e0196ab0da73 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 15:31:06 +0200 Subject: [PATCH 19/27] chore: lint --- .../src/MultichainAccountController.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/multichain-account-controller/src/MultichainAccountController.ts b/packages/multichain-account-controller/src/MultichainAccountController.ts index 540afcc89df..e2523818899 100644 --- a/packages/multichain-account-controller/src/MultichainAccountController.ts +++ b/packages/multichain-account-controller/src/MultichainAccountController.ts @@ -101,10 +101,7 @@ export class MultichainAccountController { ); if (!wallet) { - throw new Error( - 'Unknown wallet, no wallet matching this entropy source', - - ); + throw new Error('Unknown wallet, no wallet matching this entropy source'); } return wallet; From 9c0c6b654d8e98f47433aba27ccc55dde9aac0db Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 17:49:10 +0200 Subject: [PATCH 20/27] chore: rename MultichainAccountController -> MultichainAccountService --- .github/CODEOWNERS | 6 ++-- README.md | 7 +++-- .../multichain-account-controller/README.md | 17 ----------- .../src/index.ts | 2 -- .../CHANGELOG.md | 0 .../LICENSE | 0 packages/multichain-account-service/README.md | 17 +++++++++++ .../jest.config.js | 0 .../package.json | 10 +++---- .../src/MultichainAccountService.test.ts} | 28 +++++++++---------- .../src/MultichainAccountService.ts} | 22 +++++++-------- .../multichain-account-service/src/index.ts | 2 ++ .../src/providers/BaseAccountProvider.test.ts | 0 .../src/providers/BaseAccountProvider.ts | 6 ++-- .../src/providers/EvmAccountProvider.test.ts | 16 +++++------ .../src/providers/EvmAccountProvider.ts | 0 .../src/providers/SolAccountProvider.test.ts | 16 +++++------ .../src/providers/SolAccountProvider.ts | 0 .../src/tests/accounts.test.ts | 0 .../src/tests/accounts.ts | 0 .../src/tests/index.ts | 0 .../src/tests/messenger.ts | 20 ++++++------- .../src/types.ts | 22 +++++++-------- .../tsconfig.build.json | 0 .../tsconfig.json | 0 .../typedoc.json | 0 teams.json | 2 +- tsconfig.build.json | 2 +- tsconfig.json | 2 +- yarn.lock | 4 +-- 30 files changed, 102 insertions(+), 99 deletions(-) delete mode 100644 packages/multichain-account-controller/README.md delete mode 100644 packages/multichain-account-controller/src/index.ts rename packages/{multichain-account-controller => multichain-account-service}/CHANGELOG.md (100%) rename packages/{multichain-account-controller => multichain-account-service}/LICENSE (100%) create mode 100644 packages/multichain-account-service/README.md rename packages/{multichain-account-controller => multichain-account-service}/jest.config.js (100%) rename packages/{multichain-account-controller => multichain-account-service}/package.json (92%) rename packages/{multichain-account-controller/src/MultichainAccountController.test.ts => multichain-account-service/src/MultichainAccountService.test.ts} (92%) rename packages/{multichain-account-controller/src/MultichainAccountController.ts => multichain-account-service/src/MultichainAccountService.ts} (87%) create mode 100644 packages/multichain-account-service/src/index.ts rename packages/{multichain-account-controller => multichain-account-service}/src/providers/BaseAccountProvider.test.ts (100%) rename packages/{multichain-account-controller => multichain-account-service}/src/providers/BaseAccountProvider.ts (91%) rename packages/{multichain-account-controller => multichain-account-service}/src/providers/EvmAccountProvider.test.ts (82%) rename packages/{multichain-account-controller => multichain-account-service}/src/providers/EvmAccountProvider.ts (100%) rename packages/{multichain-account-controller => multichain-account-service}/src/providers/SolAccountProvider.test.ts (82%) rename packages/{multichain-account-controller => multichain-account-service}/src/providers/SolAccountProvider.ts (100%) rename packages/{multichain-account-controller => multichain-account-service}/src/tests/accounts.test.ts (100%) rename packages/{multichain-account-controller => multichain-account-service}/src/tests/accounts.ts (100%) rename packages/{multichain-account-controller => multichain-account-service}/src/tests/index.ts (100%) rename packages/{multichain-account-controller => multichain-account-service}/src/tests/messenger.ts (62%) rename packages/{multichain-account-controller => multichain-account-service}/src/types.ts (67%) rename packages/{multichain-account-controller => multichain-account-service}/tsconfig.build.json (100%) rename packages/{multichain-account-controller => multichain-account-service}/tsconfig.json (100%) rename packages/{multichain-account-controller => multichain-account-service}/typedoc.json (100%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c7c4696402f..59b852b650e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,7 +9,7 @@ ## Accounts Team /packages/accounts-controller @MetaMask/accounts-engineers /packages/multichain-transactions-controller @MetaMask/accounts-engineers -/packages/multichain-account-controller @MetaMask/accounts-engineers +/packages/multichain-account-service @MetaMask/accounts-engineers /packages/account-tree-controller @MetaMask/accounts-engineers ## Assets Team @@ -119,8 +119,8 @@ /packages/logging-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform /packages/message-manager/package.json @MetaMask/confirmations @MetaMask/core-platform /packages/message-manager/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform -/packages/multichain-account-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/multichain-account-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/multichain-account-service/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/multichain-account-service/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform /packages/multichain-api-middleware/package.json @MetaMask/wallet-api-platform-engineers @MetaMask/core-platform /packages/multichain-api-middleware/CHANGELOG.md @MetaMask/wallet-api-platform-engineers @MetaMask/core-platform /packages/name-controller/package.json @MetaMask/confirmations @MetaMask/core-platform diff --git a/README.md b/README.md index 43c33a5a1d0..4deea97c172 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/logging-controller`](packages/logging-controller) - [`@metamask/message-manager`](packages/message-manager) - [`@metamask/messenger`](packages/messenger) -- [`@metamask/multichain-account-controller`](packages/multichain-account-controller) +- [`@metamask/multichain-account-service`](packages/multichain-account-service) - [`@metamask/multichain-api-middleware`](packages/multichain-api-middleware) - [`@metamask/multichain-network-controller`](packages/multichain-network-controller) - [`@metamask/multichain-transactions-controller`](packages/multichain-transactions-controller) @@ -107,7 +107,7 @@ linkStyle default opacity:0.5 logging_controller(["@metamask/logging-controller"]); message_manager(["@metamask/message-manager"]); messenger(["@metamask/messenger"]); - multichain_account_controller(["@metamask/multichain-account-controller"]); + multichain_account_service(["@metamask/multichain-account-service"]); multichain_api_middleware(["@metamask/multichain-api-middleware"]); multichain_network_controller(["@metamask/multichain-network-controller"]); multichain_transactions_controller(["@metamask/multichain-transactions-controller"]); @@ -203,6 +203,9 @@ linkStyle default opacity:0.5 logging_controller --> controller_utils; message_manager --> base_controller; message_manager --> controller_utils; + multichain_account_service --> base_controller; + multichain_account_service --> accounts_controller; + multichain_account_service --> keyring_controller; multichain_api_middleware --> chain_agnostic_permission; multichain_api_middleware --> controller_utils; multichain_api_middleware --> json_rpc_engine; diff --git a/packages/multichain-account-controller/README.md b/packages/multichain-account-controller/README.md deleted file mode 100644 index a112d50bb6d..00000000000 --- a/packages/multichain-account-controller/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# `@metamask/multichain-account-controller` - -Multichain account stateless controller. - -This controller provides operations and functionalities around multichain accounts and wallets. - -## Installation - -`yarn add @metamask/multichain-account-controller` - -or - -`npm install @metamask/multichain-account-controller` - -## Contributing - -This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/multichain-account-controller/src/index.ts b/packages/multichain-account-controller/src/index.ts deleted file mode 100644 index a73c695eba3..00000000000 --- a/packages/multichain-account-controller/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { MultichainAccountControllerMessenger } from './types'; -export { MultichainAccountController } from './MultichainAccountController'; diff --git a/packages/multichain-account-controller/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md similarity index 100% rename from packages/multichain-account-controller/CHANGELOG.md rename to packages/multichain-account-service/CHANGELOG.md diff --git a/packages/multichain-account-controller/LICENSE b/packages/multichain-account-service/LICENSE similarity index 100% rename from packages/multichain-account-controller/LICENSE rename to packages/multichain-account-service/LICENSE diff --git a/packages/multichain-account-service/README.md b/packages/multichain-account-service/README.md new file mode 100644 index 00000000000..ee795b4005d --- /dev/null +++ b/packages/multichain-account-service/README.md @@ -0,0 +1,17 @@ +# `@metamask/multichain-account-service` + +Multichain account service. + +This service provides operations and functionalities around multichain accounts and wallets. + +## Installation + +`yarn add @metamask/multichain-account-service` + +or + +`npm install @metamask/multichain-account-service` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/multichain-account-controller/jest.config.js b/packages/multichain-account-service/jest.config.js similarity index 100% rename from packages/multichain-account-controller/jest.config.js rename to packages/multichain-account-service/jest.config.js diff --git a/packages/multichain-account-controller/package.json b/packages/multichain-account-service/package.json similarity index 92% rename from packages/multichain-account-controller/package.json rename to packages/multichain-account-service/package.json index 92d9177e98d..7823a5d992d 100644 --- a/packages/multichain-account-controller/package.json +++ b/packages/multichain-account-service/package.json @@ -1,12 +1,12 @@ { - "name": "@metamask/multichain-account-controller", + "name": "@metamask/multichain-account-service", "version": "0.0.0", - "description": "Controller to manage multichain accounts", + "description": "Service to manage multichain accounts", "keywords": [ "MetaMask", "Ethereum" ], - "homepage": "https://github.com/MetaMask/core/tree/main/packages/multichain-account-controller#readme", + "homepage": "https://github.com/MetaMask/core/tree/main/packages/multichain-account-service#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, @@ -37,8 +37,8 @@ "scripts": { "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", "build:docs": "typedoc", - "changelog:update": "../../scripts/update-changelog.sh @metamask/multichain-account-controller", - "changelog:validate": "../../scripts/validate-changelog.sh @metamask/multichain-account-controller", + "changelog:update": "../../scripts/update-changelog.sh @metamask/multichain-account-service", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/multichain-account-service", "publish:preview": "yarn npm publish --tag preview", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", diff --git a/packages/multichain-account-controller/src/MultichainAccountController.test.ts b/packages/multichain-account-service/src/MultichainAccountService.test.ts similarity index 92% rename from packages/multichain-account-controller/src/MultichainAccountController.test.ts rename to packages/multichain-account-service/src/MultichainAccountService.test.ts index b4876ccfd00..2a4ccafe567 100644 --- a/packages/multichain-account-controller/src/MultichainAccountController.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.test.ts @@ -5,11 +5,11 @@ import { EthAccountType, SolAccountType } from '@metamask/keyring-api'; import type { KeyringObject } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { MultichainAccountController } from './MultichainAccountController'; +import { MultichainAccountService } from './MultichainAccountService'; import { EvmAccountProvider } from './providers/EvmAccountProvider'; import { SolAccountProvider } from './providers/SolAccountProvider'; import { - getMultichainAccountControllerMessenger, + getMultichainAccountServiceMessenger, getRootMessenger, MOCK_HARDWARE_ACCOUNT_1, MOCK_HD_ACCOUNT_1, @@ -23,9 +23,9 @@ import { import type { AllowedActions, AllowedEvents, - MultichainAccountControllerActions, - MultichainAccountControllerEvents, - MultichainAccountControllerMessenger, + MultichainAccountServiceActions, + MultichainAccountServiceEvents, + MultichainAccountServiceMessenger, } from './types'; // Mock providers. @@ -56,7 +56,7 @@ type Mocks = { function mockAccountProvider( providerClass: new ( - messenger: MultichainAccountControllerMessenger, + messenger: MultichainAccountServiceMessenger, ) => Provider, mocks: MockAccountProvider, accounts: InternalAccount[], @@ -77,16 +77,16 @@ function setup({ accounts, }: { messenger?: Messenger< - MultichainAccountControllerActions | AllowedActions, - MultichainAccountControllerEvents | AllowedEvents + MultichainAccountServiceActions | AllowedActions, + MultichainAccountServiceEvents | AllowedEvents >; keyrings?: KeyringObject[]; accounts?: InternalAccount[]; } = {}): { - controller: MultichainAccountController; + controller: MultichainAccountService; messenger: Messenger< - MultichainAccountControllerActions | AllowedActions, - MultichainAccountControllerEvents | AllowedEvents + MultichainAccountServiceActions | AllowedActions, + MultichainAccountServiceEvents | AllowedEvents >; mocks: Mocks; } { @@ -133,15 +133,15 @@ function setup({ ); } - const controller = new MultichainAccountController({ - messenger: getMultichainAccountControllerMessenger(messenger), + const controller = new MultichainAccountService({ + messenger: getMultichainAccountServiceMessenger(messenger), }); controller.init(); return { controller, messenger, mocks }; } -describe('MultichainAccountController', () => { +describe('MultichainAccountService', () => { describe('getMultichainAccounts', () => { it('gets multichain accounts', () => { const { controller } = setup({ diff --git a/packages/multichain-account-controller/src/MultichainAccountController.ts b/packages/multichain-account-service/src/MultichainAccountService.ts similarity index 87% rename from packages/multichain-account-controller/src/MultichainAccountController.ts rename to packages/multichain-account-service/src/MultichainAccountService.ts index e2523818899..2868ed1df5d 100644 --- a/packages/multichain-account-controller/src/MultichainAccountController.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -14,20 +14,20 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import { EvmAccountProvider } from './providers/EvmAccountProvider'; import { SolAccountProvider } from './providers/SolAccountProvider'; -import type { MultichainAccountControllerMessenger } from './types'; +import type { MultichainAccountServiceMessenger } from './types'; /** - * The options that {@link MultichainAccountController} takes. + * The options that {@link MultichainAccountService} takes. */ -type MultichainAccountControllerOptions = { - messenger: MultichainAccountControllerMessenger; +type MultichainAccountServiceOptions = { + messenger: MultichainAccountServiceMessenger; }; /** - * Stateless controller to expose multichain accounts capabilities. + * Service to expose multichain accounts capabilities. */ -export class MultichainAccountController { - readonly #messenger: MultichainAccountControllerMessenger; +export class MultichainAccountService { + readonly #messenger: MultichainAccountServiceMessenger; readonly #providers: AccountProvider[]; @@ -37,13 +37,13 @@ export class MultichainAccountController { >; /** - * Constructs a new MultichainAccountController. + * Constructs a new MultichainAccountService. * * @param options - The options. * @param options.messenger - The messenger suited to this - * MultichainAccountController. + * MultichainAccountService. */ - constructor({ messenger }: MultichainAccountControllerOptions) { + constructor({ messenger }: MultichainAccountServiceOptions) { this.#messenger = messenger; this.#wallets = new Map(); // TODO: Rely on keyring capabilities once the keyring API is used by all keyrings. @@ -54,7 +54,7 @@ export class MultichainAccountController { } /** - * Initialize the controller and constructs the internal reprensentation of + * Initialize the service and constructs the internal reprensentation of * multichain accounts and wallets. */ init(): void { diff --git a/packages/multichain-account-service/src/index.ts b/packages/multichain-account-service/src/index.ts new file mode 100644 index 00000000000..a6576e9e295 --- /dev/null +++ b/packages/multichain-account-service/src/index.ts @@ -0,0 +1,2 @@ +export type { MultichainAccountServiceMessenger } from './types'; +export { MultichainAccountService } from './MultichainAccountService'; diff --git a/packages/multichain-account-controller/src/providers/BaseAccountProvider.test.ts b/packages/multichain-account-service/src/providers/BaseAccountProvider.test.ts similarity index 100% rename from packages/multichain-account-controller/src/providers/BaseAccountProvider.test.ts rename to packages/multichain-account-service/src/providers/BaseAccountProvider.test.ts diff --git a/packages/multichain-account-controller/src/providers/BaseAccountProvider.ts b/packages/multichain-account-service/src/providers/BaseAccountProvider.ts similarity index 91% rename from packages/multichain-account-controller/src/providers/BaseAccountProvider.ts rename to packages/multichain-account-service/src/providers/BaseAccountProvider.ts index fa362a63775..dacc4ceab62 100644 --- a/packages/multichain-account-controller/src/providers/BaseAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/BaseAccountProvider.ts @@ -7,7 +7,7 @@ import type { import { KeyringAccountEntropyTypeOption } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import type { MultichainAccountControllerMessenger } from '../types'; +import type { MultichainAccountServiceMessenger } from '../types'; export type Bip44Account = Account & { options: { @@ -40,9 +40,9 @@ export function isBip44Account( export abstract class BaseAccountProvider implements AccountProvider { - protected readonly messenger: MultichainAccountControllerMessenger; + protected readonly messenger: MultichainAccountServiceMessenger; - constructor(messenger: MultichainAccountControllerMessenger) { + constructor(messenger: MultichainAccountServiceMessenger) { this.messenger = messenger; } diff --git a/packages/multichain-account-controller/src/providers/EvmAccountProvider.test.ts b/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts similarity index 82% rename from packages/multichain-account-controller/src/providers/EvmAccountProvider.test.ts rename to packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts index b78d88c1d0e..459da3643a7 100644 --- a/packages/multichain-account-controller/src/providers/EvmAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts @@ -3,7 +3,7 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import { EvmAccountProvider } from './EvmAccountProvider'; import { - getMultichainAccountControllerMessenger, + getMultichainAccountServiceMessenger, getRootMessenger, MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2, @@ -11,8 +11,8 @@ import { import type { AllowedActions, AllowedEvents, - MultichainAccountControllerActions, - MultichainAccountControllerEvents, + MultichainAccountServiceActions, + MultichainAccountServiceEvents, } from '../types'; /** @@ -28,15 +28,15 @@ function setup({ accounts = [], }: { messenger?: Messenger< - MultichainAccountControllerActions | AllowedActions, - MultichainAccountControllerEvents | AllowedEvents + MultichainAccountServiceActions | AllowedActions, + MultichainAccountServiceEvents | AllowedEvents >; accounts?: InternalAccount[]; } = {}): { provider: EvmAccountProvider; messenger: Messenger< - MultichainAccountControllerActions | AllowedActions, - MultichainAccountControllerEvents | AllowedEvents + MultichainAccountServiceActions | AllowedActions, + MultichainAccountServiceEvents | AllowedEvents >; } { messenger.registerActionHandler( @@ -45,7 +45,7 @@ function setup({ ); const provider = new EvmAccountProvider( - getMultichainAccountControllerMessenger(messenger), + getMultichainAccountServiceMessenger(messenger), ); return { diff --git a/packages/multichain-account-controller/src/providers/EvmAccountProvider.ts b/packages/multichain-account-service/src/providers/EvmAccountProvider.ts similarity index 100% rename from packages/multichain-account-controller/src/providers/EvmAccountProvider.ts rename to packages/multichain-account-service/src/providers/EvmAccountProvider.ts diff --git a/packages/multichain-account-controller/src/providers/SolAccountProvider.test.ts b/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts similarity index 82% rename from packages/multichain-account-controller/src/providers/SolAccountProvider.test.ts rename to packages/multichain-account-service/src/providers/SolAccountProvider.test.ts index 638fffc9303..83a13ba0ed4 100644 --- a/packages/multichain-account-controller/src/providers/SolAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts @@ -3,7 +3,7 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import { SolAccountProvider } from './SolAccountProvider'; import { - getMultichainAccountControllerMessenger, + getMultichainAccountServiceMessenger, getRootMessenger, MOCK_HD_ACCOUNT_1, MOCK_SNAP_ACCOUNT_1, @@ -11,8 +11,8 @@ import { import type { AllowedActions, AllowedEvents, - MultichainAccountControllerActions, - MultichainAccountControllerEvents, + MultichainAccountServiceActions, + MultichainAccountServiceEvents, } from '../types'; /** @@ -28,15 +28,15 @@ function setup({ accounts = [], }: { messenger?: Messenger< - MultichainAccountControllerActions | AllowedActions, - MultichainAccountControllerEvents | AllowedEvents + MultichainAccountServiceActions | AllowedActions, + MultichainAccountServiceEvents | AllowedEvents >; accounts?: InternalAccount[]; } = {}): { provider: SolAccountProvider; messenger: Messenger< - MultichainAccountControllerActions | AllowedActions, - MultichainAccountControllerEvents | AllowedEvents + MultichainAccountServiceActions | AllowedActions, + MultichainAccountServiceEvents | AllowedEvents >; } { messenger.registerActionHandler( @@ -45,7 +45,7 @@ function setup({ ); const provider = new SolAccountProvider( - getMultichainAccountControllerMessenger(messenger), + getMultichainAccountServiceMessenger(messenger), ); return { diff --git a/packages/multichain-account-controller/src/providers/SolAccountProvider.ts b/packages/multichain-account-service/src/providers/SolAccountProvider.ts similarity index 100% rename from packages/multichain-account-controller/src/providers/SolAccountProvider.ts rename to packages/multichain-account-service/src/providers/SolAccountProvider.ts diff --git a/packages/multichain-account-controller/src/tests/accounts.test.ts b/packages/multichain-account-service/src/tests/accounts.test.ts similarity index 100% rename from packages/multichain-account-controller/src/tests/accounts.test.ts rename to packages/multichain-account-service/src/tests/accounts.test.ts diff --git a/packages/multichain-account-controller/src/tests/accounts.ts b/packages/multichain-account-service/src/tests/accounts.ts similarity index 100% rename from packages/multichain-account-controller/src/tests/accounts.ts rename to packages/multichain-account-service/src/tests/accounts.ts diff --git a/packages/multichain-account-controller/src/tests/index.ts b/packages/multichain-account-service/src/tests/index.ts similarity index 100% rename from packages/multichain-account-controller/src/tests/index.ts rename to packages/multichain-account-service/src/tests/index.ts diff --git a/packages/multichain-account-controller/src/tests/messenger.ts b/packages/multichain-account-service/src/tests/messenger.ts similarity index 62% rename from packages/multichain-account-controller/src/tests/messenger.ts rename to packages/multichain-account-service/src/tests/messenger.ts index a9a7768d4d4..be139d579e6 100644 --- a/packages/multichain-account-controller/src/tests/messenger.ts +++ b/packages/multichain-account-service/src/tests/messenger.ts @@ -3,9 +3,9 @@ import { Messenger } from '@metamask/base-controller'; import type { AllowedActions, AllowedEvents, - MultichainAccountControllerActions, - MultichainAccountControllerEvents, - MultichainAccountControllerMessenger, + MultichainAccountServiceActions, + MultichainAccountServiceEvents, + MultichainAccountServiceMessenger, } from '../types'; /** @@ -15,22 +15,22 @@ import type { */ export function getRootMessenger() { return new Messenger< - MultichainAccountControllerActions | AllowedActions, - MultichainAccountControllerEvents | AllowedEvents + MultichainAccountServiceActions | AllowedActions, + MultichainAccountServiceEvents | AllowedEvents >(); } /** - * Retrieves a restricted messenger for the MultichainAccountController. + * Retrieves a restricted messenger for the MultichainAccountService. * * @param messenger - The root messenger instance. Defaults to a new Messenger created by getRootMessenger(). - * @returns The restricted messenger for the MultichainAccountController. + * @returns The restricted messenger for the MultichainAccountService. */ -export function getMultichainAccountControllerMessenger( +export function getMultichainAccountServiceMessenger( messenger: ReturnType, -): MultichainAccountControllerMessenger { +): MultichainAccountServiceMessenger { return messenger.getRestricted({ - name: 'MultichainAccountController', + name: 'MultichainAccountService', allowedEvents: ['KeyringController:stateChange'], allowedActions: [ 'AccountsController:getAccount', diff --git a/packages/multichain-account-controller/src/types.ts b/packages/multichain-account-service/src/types.ts similarity index 67% rename from packages/multichain-account-controller/src/types.ts rename to packages/multichain-account-service/src/types.ts index 07986646ae7..129ae0c853a 100644 --- a/packages/multichain-account-controller/src/types.ts +++ b/packages/multichain-account-service/src/types.ts @@ -12,18 +12,18 @@ import type { import type { HandleSnapRequest as SnapControllerHandleSnapRequestAction } from '@metamask/snaps-controllers'; /** - * All actions that {@link MultichainAccountController} registers so that other + * All actions that {@link MultichainAccountService} registers so that other * modules can call them. */ -export type MultichainAccountControllerActions = never; +export type MultichainAccountServiceActions = never; /** - * All events that {@link MultichainAccountController} publishes so that other modules + * All events that {@link MultichainAccountService} publishes so that other modules * can subscribe to them. */ -export type MultichainAccountControllerEvents = never; +export type MultichainAccountServiceEvents = never; /** - * All actions registered by other modules that {@link MultichainAccountController} + * All actions registered by other modules that {@link MultichainAccountService} * calls. */ export type AllowedActions = @@ -35,19 +35,19 @@ export type AllowedActions = | KeyringControllerGetStateAction; /** - * All events published by other modules that {@link MultichainAccountController} + * All events published by other modules that {@link MultichainAccountService} * subscribes to. */ export type AllowedEvents = KeyringControllerStateChangeEvent; /** * The messenger restricted to actions and events that - * {@link MultichainAccountController} needs to access. + * {@link MultichainAccountService} needs to access. */ -export type MultichainAccountControllerMessenger = RestrictedMessenger< - 'MultichainAccountController', - MultichainAccountControllerActions | AllowedActions, - MultichainAccountControllerEvents | AllowedEvents, +export type MultichainAccountServiceMessenger = RestrictedMessenger< + 'MultichainAccountService', + MultichainAccountServiceActions | AllowedActions, + MultichainAccountServiceEvents | AllowedEvents, AllowedActions['type'], AllowedEvents['type'] >; diff --git a/packages/multichain-account-controller/tsconfig.build.json b/packages/multichain-account-service/tsconfig.build.json similarity index 100% rename from packages/multichain-account-controller/tsconfig.build.json rename to packages/multichain-account-service/tsconfig.build.json diff --git a/packages/multichain-account-controller/tsconfig.json b/packages/multichain-account-service/tsconfig.json similarity index 100% rename from packages/multichain-account-controller/tsconfig.json rename to packages/multichain-account-service/tsconfig.json diff --git a/packages/multichain-account-controller/typedoc.json b/packages/multichain-account-service/typedoc.json similarity index 100% rename from packages/multichain-account-controller/typedoc.json rename to packages/multichain-account-service/typedoc.json diff --git a/teams.json b/teams.json index 3f8031ec360..444c99b3d2b 100644 --- a/teams.json +++ b/teams.json @@ -24,7 +24,7 @@ "metamask/logging-controller": "team-confirmations", "metamask/message-manager": "team-confirmations", "metamask/messenger": "team-wallet-framework", - "metamask/multichain-account-controller": "team-accounts", + "metamask/multichain-account-service": "team-accounts", "metamask/multichain-api-middleware": "team-wallet-api-platform", "metamask/multichain-network-controller": "team-wallet-api-platform", "metamask/name-controller": "team-confirmations", diff --git a/tsconfig.build.json b/tsconfig.build.json index 22e00decb2f..4316194d801 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -28,7 +28,7 @@ { "path": "./packages/logging-controller/tsconfig.build.json" }, { "path": "./packages/message-manager/tsconfig.build.json" }, { "path": "./packages/messenger/tsconfig.build.json" }, - { "path": "./packages/multichain-account-controller/tsconfig.build.json" }, + { "path": "./packages/multichain-account-service/tsconfig.build.json" }, { "path": "./packages/multichain-api-middleware/tsconfig.build.json" }, { "path": "./packages/multichain-network-controller/tsconfig.build.json" }, { diff --git a/tsconfig.json b/tsconfig.json index fb819e0ab5f..ed869d305fa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,7 +33,7 @@ { "path": "./packages/keyring-controller" }, { "path": "./packages/message-manager" }, { "path": "./packages/messenger" }, - { "path": "./packages/multichain-account-controller" }, + { "path": "./packages/multichain-account-service" }, { "path": "./packages/multichain-api-middleware" }, { "path": "./packages/multichain-network-controller" }, { "path": "./packages/multichain-transactions-controller" }, diff --git a/yarn.lock b/yarn.lock index c24588cb7ee..f9ae07366ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3807,9 +3807,9 @@ __metadata: languageName: node linkType: hard -"@metamask/multichain-account-controller@workspace:packages/multichain-account-controller": +"@metamask/multichain-account-service@workspace:packages/multichain-account-service": version: 0.0.0-use.local - resolution: "@metamask/multichain-account-controller@workspace:packages/multichain-account-controller" + resolution: "@metamask/multichain-account-service@workspace:packages/multichain-account-service" dependencies: "@metamask/account-api": "npm:^0.2.0" "@metamask/accounts-controller": "npm:^31.0.0" From f2c8ca5e3e28476949972070bfaa911cd004aa4b Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 17:49:36 +0200 Subject: [PATCH 21/27] chore: cosmetic --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 59b852b650e..feab726ecae 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,7 +9,7 @@ ## Accounts Team /packages/accounts-controller @MetaMask/accounts-engineers /packages/multichain-transactions-controller @MetaMask/accounts-engineers -/packages/multichain-account-service @MetaMask/accounts-engineers +/packages/multichain-account-service @MetaMask/accounts-engineers /packages/account-tree-controller @MetaMask/accounts-engineers ## Assets Team From b26e62946d91ef99d6450128d483dd8caa1c549d Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 17:51:30 +0200 Subject: [PATCH 22/27] chore: cosmetic --- .../src/MultichainAccountService.test.ts | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService.test.ts b/packages/multichain-account-service/src/MultichainAccountService.test.ts index 2a4ccafe567..e2a33a71261 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.test.ts @@ -55,9 +55,7 @@ type Mocks = { }; function mockAccountProvider( - providerClass: new ( - messenger: MultichainAccountServiceMessenger, - ) => Provider, + providerClass: new (messenger: MultichainAccountServiceMessenger) => Provider, mocks: MockAccountProvider, accounts: InternalAccount[], type: KeyringAccount['type'], @@ -83,7 +81,7 @@ function setup({ keyrings?: KeyringObject[]; accounts?: InternalAccount[]; } = {}): { - controller: MultichainAccountService; + service: MultichainAccountService; messenger: Messenger< MultichainAccountServiceActions | AllowedActions, MultichainAccountServiceEvents | AllowedEvents @@ -133,18 +131,18 @@ function setup({ ); } - const controller = new MultichainAccountService({ + const service = new MultichainAccountService({ messenger: getMultichainAccountServiceMessenger(messenger), }); - controller.init(); + service.init(); - return { controller, messenger, mocks }; + return { service, messenger, mocks }; } describe('MultichainAccountService', () => { describe('getMultichainAccounts', () => { it('gets multichain accounts', () => { - const { controller } = setup({ + const { service } = setup({ accounts: [ // Wallet 1: MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) @@ -167,19 +165,19 @@ describe('MultichainAccountService', () => { }); expect( - controller.getMultichainAccounts({ + service.getMultichainAccounts({ entropySource: MOCK_HD_KEYRING_1.metadata.id, }), ).toHaveLength(1); expect( - controller.getMultichainAccounts({ + service.getMultichainAccounts({ entropySource: MOCK_HD_KEYRING_2.metadata.id, }), ).toHaveLength(1); }); it('gets multichain accounts with multiple wallets', () => { - const { controller } = setup({ + const { service } = setup({ accounts: [ // Wallet 1: MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) @@ -193,7 +191,7 @@ describe('MultichainAccountService', () => { ], }); - const multichainAccounts = controller.getMultichainAccounts({ + const multichainAccounts = service.getMultichainAccounts({ entropySource: MOCK_HD_KEYRING_1.metadata.id, }); expect(multichainAccounts).toHaveLength(2); // Group index 0 + 1. @@ -208,7 +206,7 @@ describe('MultichainAccountService', () => { }); it('throws if trying to access an unknown wallet', () => { - const { controller } = setup({ + const { service } = setup({ keyrings: [MOCK_HD_KEYRING_1], accounts: [ // Wallet 1: @@ -222,7 +220,7 @@ describe('MultichainAccountService', () => { // Wallet 2 should not exist, thus, this should throw. expect(() => // NOTE: We use `getMultichainAccounts` which uses `#getWallet` under the hood. - controller.getMultichainAccounts({ + service.getMultichainAccounts({ entropySource: MOCK_HD_KEYRING_2.metadata.id, }), ).toThrow('Unknown wallet, no wallet matching this entropy source'); @@ -242,12 +240,12 @@ describe('MultichainAccountService', () => { .withGroupIndex(1) .get(), ]; - const { controller } = setup({ + const { service } = setup({ accounts, }); const groupIndex = 1; - const multichainAccount = controller.getMultichainAccount({ + const multichainAccount = service.getMultichainAccount({ entropySource: MOCK_HD_KEYRING_1.metadata.id, groupIndex, }); @@ -259,7 +257,7 @@ describe('MultichainAccountService', () => { }); it('throws if trying to access an out-of-bound group index', () => { - const { controller } = setup({ + const { service } = setup({ accounts: [ // Wallet 1: MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) @@ -271,7 +269,7 @@ describe('MultichainAccountService', () => { const groupIndex = 1; expect(() => - controller.getMultichainAccount({ + service.getMultichainAccount({ entropySource: MOCK_HD_KEYRING_1.metadata.id, groupIndex, }), @@ -289,14 +287,14 @@ describe('MultichainAccountService', () => { .withGroupIndex(0) .get(), ]; - const { controller, messenger, mocks } = setup({ + const { service, messenger, mocks } = setup({ keyrings, accounts, }); // This wallet does not exist yet. expect(() => - controller.getMultichainAccounts({ + service.getMultichainAccounts({ entropySource: MOCK_HD_KEYRING_2.metadata.id, }), ).toThrow('Unknown wallet, no wallet matching this entropy source'); @@ -325,7 +323,7 @@ describe('MultichainAccountService', () => { // We should now be able to query that wallet. expect( - controller.getMultichainAccounts({ + service.getMultichainAccounts({ entropySource: MOCK_HD_KEYRING_2.metadata.id, }), ).toHaveLength(1); From d6dd7f07abb021d24243df4b4d27f61650be3a48 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 23:33:42 +0200 Subject: [PATCH 23/27] test: remove unneeded `beforeEach` Co-authored-by: Elliot Winkler --- .../src/providers/BaseAccountProvider.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/multichain-account-service/src/providers/BaseAccountProvider.test.ts b/packages/multichain-account-service/src/providers/BaseAccountProvider.test.ts index c9be7f905f7..d379a9514e6 100644 --- a/packages/multichain-account-service/src/providers/BaseAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/BaseAccountProvider.test.ts @@ -6,10 +6,6 @@ import { isBip44Account } from './BaseAccountProvider'; import { MOCK_HD_ACCOUNT_1 } from '../tests'; describe('isBip44Account', () => { - beforeEach(() => { - jest.resetAllMocks(); - }); - it('returns true if an account is BIP-44 compatible', () => { expect(isBip44Account(MOCK_HD_ACCOUNT_1)).toBe(true); }); From 6a1d47233aa2cf4d279e337cbc5fdbf386da95a5 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 23:36:51 +0200 Subject: [PATCH 24/27] chore: export events + actions Co-authored-by: Elliot Winkler --- packages/multichain-account-service/src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/multichain-account-service/src/index.ts b/packages/multichain-account-service/src/index.ts index a6576e9e295..f4d1271304a 100644 --- a/packages/multichain-account-service/src/index.ts +++ b/packages/multichain-account-service/src/index.ts @@ -1,2 +1,6 @@ -export type { MultichainAccountServiceMessenger } from './types'; +export type { + MultichainAccountServiceActions, + MultichainAccountServiceEvents, + MultichainAccountServiceMessenger, +} from './types'; export { MultichainAccountService } from './MultichainAccountService'; From 18a63581c8fa1501b08003b3e6e4b0d13e8586a7 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 23:41:42 +0200 Subject: [PATCH 25/27] test: remove accounts.test.ts --- .../multichain-account-service/package.json | 2 - .../src/tests/accounts.test.ts | 41 ------------------- .../src/tests/accounts.ts | 23 ++--------- yarn.lock | 2 - 4 files changed, 4 insertions(+), 64 deletions(-) delete mode 100644 packages/multichain-account-service/src/tests/accounts.test.ts diff --git a/packages/multichain-account-service/package.json b/packages/multichain-account-service/package.json index 7823a5d992d..ad03fd3e785 100644 --- a/packages/multichain-account-service/package.json +++ b/packages/multichain-account-service/package.json @@ -64,14 +64,12 @@ "@metamask/providers": "^22.1.0", "@metamask/snaps-controllers": "^14.0.1", "@types/jest": "^27.4.1", - "@types/uuid": "^8.3.0", "deepmerge": "^4.2.2", "jest": "^27.5.1", "ts-jest": "^27.1.4", "typedoc": "^0.24.8", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.2.2", - "uuid": "^8.3.2", "webextension-polyfill": "^0.12.0" }, "peerDependencies": { diff --git a/packages/multichain-account-service/src/tests/accounts.test.ts b/packages/multichain-account-service/src/tests/accounts.test.ts deleted file mode 100644 index 0af95584332..00000000000 --- a/packages/multichain-account-service/src/tests/accounts.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - MOCK_HD_ACCOUNT_1, - MOCK_SNAP_ACCOUNT_2, - MockAccountBuilder, -} from './accounts'; - -describe('MockAccountBuilder', () => { - it('updates the account ID', () => { - const account = MOCK_HD_ACCOUNT_1; - const mockAccount = MockAccountBuilder.from(account).withUuid().get(); - - expect(account.id).not.toStrictEqual(mockAccount.id); - }); - - it('adds a suffix to the account address', () => { - const suffix = 'foo'; - - const account = MOCK_HD_ACCOUNT_1; - const mockAccount = MockAccountBuilder.from(account) - .withAddressSuffix(suffix) - .get(); - - expect(mockAccount.address.endsWith(suffix)).toBe(true); - }); - - it('throws if trying to update entropy source for non-BIP-44 accounts', () => { - const account = MOCK_SNAP_ACCOUNT_2; // Not a BIP-44 account. - - expect(() => - MockAccountBuilder.from(account).withEntropySource('test').get(), - ).toThrow('Invalid BIP-44 account'); - }); - - it('throws if trying to update group index for non-BIP-44 accounts', () => { - const account = MOCK_SNAP_ACCOUNT_2; // Not a BIP-44 account. - - expect(() => - MockAccountBuilder.from(account).withGroupIndex(10).get(), - ).toThrow('Invalid BIP-44 account'); - }); -}); diff --git a/packages/multichain-account-service/src/tests/accounts.ts b/packages/multichain-account-service/src/tests/accounts.ts index d1068c832d9..10191238f50 100644 --- a/packages/multichain-account-service/src/tests/accounts.ts +++ b/packages/multichain-account-service/src/tests/accounts.ts @@ -10,7 +10,6 @@ import { } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { v4 as uuid } from 'uuid'; import { isBip44Account } from '../providers/BaseAccountProvider'; @@ -171,31 +170,17 @@ export class MockAccountBuilder { return new MockAccountBuilder(account); } - withUuid() { - this.#account.id = uuid(); - return this; - } - - withAddressSuffix(suffix: string) { - this.#account.address += suffix; - return this; - } - withEntropySource(entropySource: EntropySourceId) { - if (!isBip44Account(this.#account)) { - throw new Error('Invalid BIP-44 account'); + if (isBip44Account(this.#account)) { + this.#account.options.entropy.id = entropySource; } - - this.#account.options.entropy.id = entropySource; return this; } withGroupIndex(groupIndex: number) { - if (!isBip44Account(this.#account)) { - throw new Error('Invalid BIP-44 account'); + if (isBip44Account(this.#account)) { + this.#account.options.entropy.groupIndex = groupIndex; } - - this.#account.options.entropy.groupIndex = groupIndex; return this; } diff --git a/yarn.lock b/yarn.lock index f9ae07366ad..d3389ae80c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3826,14 +3826,12 @@ __metadata: "@metamask/snaps-utils": "npm:^11.0.0" "@metamask/superstruct": "npm:^3.1.0" "@types/jest": "npm:^27.4.1" - "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" ts-jest: "npm:^27.1.4" typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.2.2" - uuid: "npm:^8.3.2" webextension-polyfill: "npm:^0.12.0" peerDependencies: "@metamask/accounts-controller": ^31.0.0 From 7399ee396697f01296585b0f2b67147acac0731f Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 23:47:06 +0200 Subject: [PATCH 26/27] refactor: use event selector --- .../src/MultichainAccountService.ts | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index 2868ed1df5d..d445c895ada 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -8,7 +8,10 @@ import { type MultichainAccount, } from '@metamask/account-api'; import type { EntropySourceId } from '@metamask/keyring-api'; -import type { KeyringObject } from '@metamask/keyring-controller'; +import type { + KeyringControllerState, + KeyringObject, +} from '@metamask/keyring-controller'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; @@ -23,6 +26,16 @@ type MultichainAccountServiceOptions = { messenger: MultichainAccountServiceMessenger; }; +/** + * Select keyrings from keyring controller state. + * + * @param state - The keyring controller state. + * @returns The keyrings. + */ +function selectKeyringControllerKeyrings(state: KeyringControllerState) { + return state.keyrings; +} + /** * Service to expose multichain accounts capabilities. */ @@ -59,15 +72,16 @@ export class MultichainAccountService { */ init(): void { // Gather all entropy sources first. - const { keyrings } = this.#messenger.call('KeyringController:getState'); - this.#setMultichainAccountWallets(keyrings); - - // TODO: For now, we to every `KeyringController` state change to detect when - // new entropy sources/SRPs are being added. Having a dedicated event when - // new keyrings are added would make this more efficient. - this.#messenger.subscribe('KeyringController:stateChange', (state) => { - this.#setMultichainAccountWallets(state.keyrings); - }); + const state = this.#messenger.call('KeyringController:getState'); + this.#setMultichainAccountWallets(state.keyrings); + + this.#messenger.subscribe( + 'KeyringController:stateChange', + (keyrings) => { + this.#setMultichainAccountWallets(keyrings); + }, + selectKeyringControllerKeyrings, + ); } #setMultichainAccountWallets(keyrings: KeyringObject[]) { From 90a98a37f2b0f7477fcd8677fa9e4d3806e521bc Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 18 Jul 2025 23:53:12 +0200 Subject: [PATCH 27/27] chore: update changelog --- packages/multichain-account-service/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index b518709c7b8..619de97cc8e 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -7,4 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `MultichainAccountService` ([#6141](https://github.com/MetaMask/core/pull/6141)) + - This service manages multichain accounts/wallets. + [Unreleased]: https://github.com/MetaMask/core/