From 9333e1b22069febb38eda63d8cfc4cd6f07ced79 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 16 Jul 2026 13:01:58 +0200 Subject: [PATCH 01/19] feat: add entropy-controller --- .github/CODEOWNERS | 3 + README.md | 2 + packages/entropy-controller/CHANGELOG.md | 10 + packages/entropy-controller/LICENSE | 6 + packages/entropy-controller/LICENSE.APACHE2 | 201 ++++++++++++++++++ packages/entropy-controller/LICENSE.MIT | 21 ++ packages/entropy-controller/README.md | 15 ++ packages/entropy-controller/jest.config.js | 26 +++ packages/entropy-controller/package.json | 74 +++++++ .../src/entropy-controller.test.ts | 180 ++++++++++++++++ .../src/entropy-controller.ts | 192 +++++++++++++++++ packages/entropy-controller/src/index.ts | 13 ++ .../entropy-controller/tsconfig.build.json | 17 ++ packages/entropy-controller/tsconfig.json | 15 ++ packages/entropy-controller/typedoc.json | 7 + tsconfig.build.json | 3 + yarn.lock | 19 ++ 17 files changed, 804 insertions(+) create mode 100644 packages/entropy-controller/CHANGELOG.md create mode 100644 packages/entropy-controller/LICENSE create mode 100644 packages/entropy-controller/LICENSE.APACHE2 create mode 100644 packages/entropy-controller/LICENSE.MIT create mode 100644 packages/entropy-controller/README.md create mode 100644 packages/entropy-controller/jest.config.js create mode 100644 packages/entropy-controller/package.json create mode 100644 packages/entropy-controller/src/entropy-controller.test.ts create mode 100644 packages/entropy-controller/src/entropy-controller.ts create mode 100644 packages/entropy-controller/src/index.ts create mode 100644 packages/entropy-controller/tsconfig.build.json create mode 100644 packages/entropy-controller/tsconfig.json create mode 100644 packages/entropy-controller/typedoc.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 49f39f1ce08..09f2af550df 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,6 +14,7 @@ /packages/profile-sync-controller @MetaMask/accounts-engineers /packages/money-account-controller @MetaMask/accounts-engineers /packages/snap-account-service @MetaMask/accounts-engineers +/packages/entropy-controller @MetaMask/accounts-engineers ## Auth Team /packages/authenticated-user-storage @MetaMask/auth-engineers @@ -280,3 +281,5 @@ /packages/money-account-upgrade-controller/CHANGELOG.md @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform /packages/snap-account-service/package.json @MetaMask/accounts-engineers @MetaMask/core-platform /packages/snap-account-service/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/entropy-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/entropy-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform diff --git a/README.md b/README.md index b8b3a2d8ca8..561b8fb9969 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ yarn skills --reset # clear saved local selection - [`@metamask/eip-7702-internal-rpc-middleware`](packages/eip-7702-internal-rpc-middleware) - [`@metamask/eip1193-permission-middleware`](packages/eip1193-permission-middleware) - [`@metamask/ens-controller`](packages/ens-controller) +- [`@metamask/entropy-controller`](packages/entropy-controller) - [`@metamask/eth-block-tracker`](packages/eth-block-tracker) - [`@metamask/eth-json-rpc-middleware`](packages/eth-json-rpc-middleware) - [`@metamask/eth-json-rpc-provider`](packages/eth-json-rpc-provider) @@ -180,6 +181,7 @@ linkStyle default opacity:0.5 eip_7702_internal_rpc_middleware(["@metamask/eip-7702-internal-rpc-middleware"]); eip1193_permission_middleware(["@metamask/eip1193-permission-middleware"]); ens_controller(["@metamask/ens-controller"]); + entropy_controller(["@metamask/entropy-controller"]); eth_block_tracker(["@metamask/eth-block-tracker"]); eth_json_rpc_middleware(["@metamask/eth-json-rpc-middleware"]); eth_json_rpc_provider(["@metamask/eth-json-rpc-provider"]); diff --git a/packages/entropy-controller/CHANGELOG.md b/packages/entropy-controller/CHANGELOG.md new file mode 100644 index 00000000000..b518709c7b8 --- /dev/null +++ b/packages/entropy-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/entropy-controller/LICENSE b/packages/entropy-controller/LICENSE new file mode 100644 index 00000000000..9ec4f4514ea --- /dev/null +++ b/packages/entropy-controller/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. diff --git a/packages/entropy-controller/LICENSE.APACHE2 b/packages/entropy-controller/LICENSE.APACHE2 new file mode 100644 index 00000000000..e6e77b08909 --- /dev/null +++ b/packages/entropy-controller/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/entropy-controller/LICENSE.MIT b/packages/entropy-controller/LICENSE.MIT new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/entropy-controller/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 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 +SOFTWARE. diff --git a/packages/entropy-controller/README.md b/packages/entropy-controller/README.md new file mode 100644 index 00000000000..484bf57230e --- /dev/null +++ b/packages/entropy-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/entropy-controller` + +Controller that manages/owns various entropies (SRPs/mnemonic, private keys, hardware wallets, etc.) + +## Installation + +`yarn add @metamask/entropy-controller` + +or + +`npm install @metamask/entropy-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/entropy-controller/jest.config.js b/packages/entropy-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/entropy-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/entropy-controller/package.json b/packages/entropy-controller/package.json new file mode 100644 index 00000000000..ac9894466bf --- /dev/null +++ b/packages/entropy-controller/package.json @@ -0,0 +1,74 @@ +{ + "name": "@metamask/entropy-controller", + "version": "0.0.0", + "description": "Controller that manages/owns various entropies (SRPs/mnemonic, private keys, hardware wallets, etc.)", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/entropy-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/entropy-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/entropy-controller", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "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": "^9.1.0", + "@metamask/messenger": "^2.0.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/entropy-controller/src/entropy-controller.test.ts b/packages/entropy-controller/src/entropy-controller.test.ts new file mode 100644 index 00000000000..3ddd6c24440 --- /dev/null +++ b/packages/entropy-controller/src/entropy-controller.test.ts @@ -0,0 +1,180 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import type { EntropyControllerMessenger } from './entropy-controller'; +import { EntropyController } from './entropy-controller'; + +describe('EntropyController', () => { + describe('constructor', () => { + it('accepts initial state', () => { + const givenState = { + entropySources: { + 'entropy-1': { + type: 'bip44:srp' as const, + metadata: {}, + }, + }, + }; + + withController({ options: { state: givenState } }, ({ controller }) => { + expect(controller.state).toStrictEqual(givenState); + }); + }); + + it('fills in missing initial state with defaults', () => { + withController(({ controller }) => { + expect(controller.state).toMatchInlineSnapshot(` + { + "entropySources": {}, + } + `); + }); + }); + }); + + describe('metadata', () => { + it('does not include entropy sources in debug snapshots', () => { + withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + }); + + it('includes entropy sources in state logs', () => { + withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "entropySources": {}, + } + `); + }); + }); + + it('persists entropy sources', () => { + withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "entropySources": {}, + } + `); + }); + }); + + it('exposes entropy sources to UI', () => { + withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "entropySources": {}, + } + `); + }); + }); + }); +}); + +/** + * The type of the messenger populated with all external actions and events + * required by the controller under test. + */ +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * The callback that `withController` calls. + */ +type WithControllerCallback = (payload: { + controller: EntropyController; + rootMessenger: RootMessenger; + controllerMessenger: EntropyControllerMessenger; +}) => ReturnValue; + +/** + * The options that `withController` takes. + */ +type WithControllerOptions = { + options: Partial[0]>; +}; + +/** + * Constructs the messenger populated with all external actions and events + * required by the controller under test. + * + * @returns The root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the controller under test. + * + * @param rootMessenger - The root messenger, with all external actions and + * events required by the controller's messenger. + * @returns The controller-specific messenger. + */ +function getMessenger( + rootMessenger: RootMessenger, +): EntropyControllerMessenger { + return new Messenger({ + namespace: 'EntropyController', + parent: rootMessenger, + }); +} + +/** + * Wrap tests for the controller under test by ensuring that the controller is + * created ahead of time with the given options. + * + * @param args - Either a function, or an options bag + a function. The options + * bag contains arguments for the controller constructor. All constructor + * arguments are optional and will be filled in with defaults as needed + * (including `messenger`). The function is called with the instantiated + * controller, root messenger, and controller messenger. + * @returns The same return value as the given function. + */ +function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): ReturnValue { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + const rootMessenger = getRootMessenger(); + const controllerMessenger = getMessenger(rootMessenger); + const controller = new EntropyController({ + messenger: controllerMessenger, + ...options, + }); + return testFunction({ controller, rootMessenger, controllerMessenger }); +} diff --git a/packages/entropy-controller/src/entropy-controller.ts b/packages/entropy-controller/src/entropy-controller.ts new file mode 100644 index 00000000000..b4b173f39f8 --- /dev/null +++ b/packages/entropy-controller/src/entropy-controller.ts @@ -0,0 +1,192 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; + +// === GENERAL === + +/** + * The name of the {@link EntropyController}, used to namespace the + * controller's actions and events and to namespace the controller's state data + * when composed with other controllers. + */ +const CONTROLLER_NAME = 'EntropyController'; + +// === TYPES === + +/** + * The category of an entropy source. + * + * - `'bip44'` — Entropy that uses BIP-44 to derive signers (e.g. SRP, hardware + * wallets). + * - `'raw'` — Entropy that exposes a single key directly, without derivation + * (e.g. imported private keys, MPC). + */ +export type EntropyCategory = 'bip44' | 'raw'; + +/** + * The type of an entropy source, expressed as `category:implementation`. + * + * @example `'bip44:srp'`, `'bip44:ledger'`, `'raw:private-key'`, `'raw:mpc'` + */ +export type EntropyType = `${EntropyCategory}:${string}`; + +/** + * Unique identifier for an entropy source. + */ +export type EntropyId = string; + +/** + * Metadata associated with an entropy source. + * + * Currently a placeholder — fields will be added as requirements emerge. + */ +export type EntropyMetadata = Record; + +// === STATE === + +/** + * Describes the shape of the state object for {@link EntropyController}. + */ +export type EntropyControllerState = { + /** + * The registry of entropy sources, keyed by their unique identifier. + * Each entry records the type and metadata of the entropy source. + */ + entropySources: { + [entropyId: EntropyId]: { + /** + * The type of the entropy source, expressed as `category:implementation` + * (e.g. `'bip44:srp'`, `'bip44:ledger'`). + */ + type: EntropyType; + + /** + * Metadata associated with the entropy source. + */ + metadata: EntropyMetadata; + }; + }; +}; + +/** + * The metadata for each property in {@link EntropyControllerState}. + */ +const entropyControllerMetadata = { + entropySources: { + includeInDebugSnapshot: false, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, +} satisfies StateMetadata; + +/** + * Constructs the default {@link EntropyController} state. This allows + * consumers to provide a partial state object when initializing the controller + * and also helps in constructing complete state objects for this controller in + * tests. + * + * @returns The default {@link EntropyController} state. + */ +export function getDefaultEntropyControllerState(): EntropyControllerState { + return { + entropySources: {}, + }; +} + +// === MESSENGER === + +/** + * Retrieves the state of the {@link EntropyController}. + */ +export type EntropyControllerGetStateAction = ControllerGetStateAction< + typeof CONTROLLER_NAME, + EntropyControllerState +>; + +/** + * Actions that {@link EntropyControllerMessenger} exposes to other consumers. + */ +export type EntropyControllerActions = EntropyControllerGetStateAction; + +/** + * Actions from other messengers that {@link EntropyControllerMessenger} calls. + */ +type AllowedActions = never; + +/** + * Published when the state of {@link EntropyController} changes. + */ +export type EntropyControllerStateChangeEvent = ControllerStateChangeEvent< + typeof CONTROLLER_NAME, + EntropyControllerState +>; + +/** + * Events that {@link EntropyControllerMessenger} exposes to other consumers. + */ +export type EntropyControllerEvents = EntropyControllerStateChangeEvent; + +/** + * Events from other messengers that {@link EntropyControllerMessenger} + * subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events accessed by + * {@link EntropyController}. + */ +export type EntropyControllerMessenger = Messenger< + typeof CONTROLLER_NAME, + EntropyControllerActions | AllowedActions, + EntropyControllerEvents | AllowedEvents +>; + +// === CONTROLLER DEFINITION === + +/** + * `EntropyController` maintains a registry that maps entropy source identifiers + * to their type and metadata. + * + * An entropy source is any provider of key material: a Secret Recovery Phrase + * (SRP), a hardware wallet (Ledger, Trezor), or an imported private key. The + * registry is the single source of truth for which entropy sources exist and + * what kind they are. Actual key material and signing operations are left to + * entropy source implementations and chain-specific keyrings. + */ +export class EntropyController extends BaseController< + typeof CONTROLLER_NAME, + EntropyControllerState, + EntropyControllerMessenger +> { + /** + * Constructs a new {@link EntropyController}. + * + * @param args - The arguments to this controller. + * @param args.messenger - The messenger suited for this controller. + * @param args.state - The desired state with which to initialize this + * controller. Missing properties will be filled in with defaults. + */ + constructor({ + messenger, + state, + }: { + messenger: EntropyControllerMessenger; + state?: Partial; + }) { + super({ + messenger, + metadata: entropyControllerMetadata, + name: CONTROLLER_NAME, + state: { + ...getDefaultEntropyControllerState(), + ...state, + }, + }); + } +} diff --git a/packages/entropy-controller/src/index.ts b/packages/entropy-controller/src/index.ts new file mode 100644 index 00000000000..3b144ac6ef6 --- /dev/null +++ b/packages/entropy-controller/src/index.ts @@ -0,0 +1,13 @@ +export { EntropyController } from './entropy-controller'; +export type { + EntropyCategory, + EntropyControllerActions, + EntropyControllerEvents, + EntropyControllerGetStateAction, + EntropyControllerMessenger, + EntropyControllerState, + EntropyControllerStateChangeEvent, + EntropyId, + EntropyMetadata, + EntropyType, +} from './entropy-controller'; diff --git a/packages/entropy-controller/tsconfig.build.json b/packages/entropy-controller/tsconfig.build.json new file mode 100644 index 00000000000..249f327913d --- /dev/null +++ b/packages/entropy-controller/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/entropy-controller/tsconfig.json b/packages/entropy-controller/tsconfig.json new file mode 100644 index 00000000000..cb296895b28 --- /dev/null +++ b/packages/entropy-controller/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../base-controller" + }, + { + "path": "../messenger" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/entropy-controller/typedoc.json b/packages/entropy-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/entropy-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 5e805550062..6aa2c96d9a2 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -112,6 +112,9 @@ { "path": "./packages/ens-controller/tsconfig.build.json" }, + { + "path": "./packages/entropy-controller/tsconfig.build.json" + }, { "path": "./packages/eth-block-tracker/tsconfig.build.json" }, diff --git a/yarn.lock b/yarn.lock index 1e4085d18fb..d8c3167a78f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6593,6 +6593,25 @@ __metadata: languageName: unknown linkType: soft +"@metamask/entropy-controller@workspace:packages/entropy-controller": + version: 0.0.0-use.local + resolution: "@metamask/entropy-controller@workspace:packages/entropy-controller" + dependencies: + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/messenger": "npm:^2.0.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/eslint-config-jest@npm:^15.0.0": version: 15.0.0 resolution: "@metamask/eslint-config-jest@npm:15.0.0" From a63764539bd561b0372ced179e3c585213a2427b Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 16 Jul 2026 13:08:48 +0200 Subject: [PATCH 02/19] chore: cosmetic --- packages/entropy-controller/src/entropy-controller.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/entropy-controller/src/entropy-controller.ts b/packages/entropy-controller/src/entropy-controller.ts index b4b173f39f8..2d76d0cb41a 100644 --- a/packages/entropy-controller/src/entropy-controller.ts +++ b/packages/entropy-controller/src/entropy-controller.ts @@ -6,8 +6,6 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; -// === GENERAL === - /** * The name of the {@link EntropyController}, used to namespace the * controller's actions and events and to namespace the controller's state data @@ -15,8 +13,6 @@ import type { Messenger } from '@metamask/messenger'; */ const CONTROLLER_NAME = 'EntropyController'; -// === TYPES === - /** * The category of an entropy source. * @@ -46,8 +42,6 @@ export type EntropyId = string; */ export type EntropyMetadata = Record; -// === STATE === - /** * Describes the shape of the state object for {@link EntropyController}. */ @@ -98,8 +92,6 @@ export function getDefaultEntropyControllerState(): EntropyControllerState { }; } -// === MESSENGER === - /** * Retrieves the state of the {@link EntropyController}. */ @@ -147,8 +139,6 @@ export type EntropyControllerMessenger = Messenger< EntropyControllerEvents | AllowedEvents >; -// === CONTROLLER DEFINITION === - /** * `EntropyController` maintains a registry that maps entropy source identifiers * to their type and metadata. From 406f6260cce3a26de65cafb68f366637c1ebec7c Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 16 Jul 2026 13:09:42 +0200 Subject: [PATCH 03/19] chore: changelog --- packages/entropy-controller/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/entropy-controller/CHANGELOG.md b/packages/entropy-controller/CHANGELOG.md index b518709c7b8..366bd0dca54 100644 --- a/packages/entropy-controller/CHANGELOG.md +++ b/packages/entropy-controller/CHANGELOG.md @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## Added + +- Add `EntropyController` ([#9538](https://github.com/MetaMask/core/pull/9538)) + [Unreleased]: https://github.com/MetaMask/core/ From 0a5bb19e490234ebc2b7593199e078b8e88efced Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 16 Jul 2026 13:49:17 +0200 Subject: [PATCH 04/19] chore: add toEntropy{Id,Fingerprint,FingerprintBytes} helpers --- packages/entropy-controller/package.json | 4 +- .../src/entropy-controller.ts | 31 +------- packages/entropy-controller/src/index.ts | 7 +- packages/entropy-controller/src/types.ts | 28 +++++++ packages/entropy-controller/src/utils.test.ts | 75 +++++++++++++++++++ packages/entropy-controller/src/utils.ts | 75 +++++++++++++++++++ yarn.lock | 2 + 7 files changed, 190 insertions(+), 32 deletions(-) create mode 100644 packages/entropy-controller/src/types.ts create mode 100644 packages/entropy-controller/src/utils.test.ts create mode 100644 packages/entropy-controller/src/utils.ts diff --git a/packages/entropy-controller/package.json b/packages/entropy-controller/package.json index ac9894466bf..d012e2b0a60 100644 --- a/packages/entropy-controller/package.json +++ b/packages/entropy-controller/package.json @@ -54,7 +54,9 @@ }, "dependencies": { "@metamask/base-controller": "^9.1.0", - "@metamask/messenger": "^2.0.0" + "@metamask/messenger": "^2.0.0", + "@noble/hashes": "^1.8.0", + "uuid": "^8.3.2" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/entropy-controller/src/entropy-controller.ts b/packages/entropy-controller/src/entropy-controller.ts index 2d76d0cb41a..3b4e504c11d 100644 --- a/packages/entropy-controller/src/entropy-controller.ts +++ b/packages/entropy-controller/src/entropy-controller.ts @@ -6,6 +6,8 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; +import type { EntropyId, EntropyMetadata, EntropyType } from './types'; + /** * The name of the {@link EntropyController}, used to namespace the * controller's actions and events and to namespace the controller's state data @@ -13,35 +15,6 @@ import type { Messenger } from '@metamask/messenger'; */ const CONTROLLER_NAME = 'EntropyController'; -/** - * The category of an entropy source. - * - * - `'bip44'` — Entropy that uses BIP-44 to derive signers (e.g. SRP, hardware - * wallets). - * - `'raw'` — Entropy that exposes a single key directly, without derivation - * (e.g. imported private keys, MPC). - */ -export type EntropyCategory = 'bip44' | 'raw'; - -/** - * The type of an entropy source, expressed as `category:implementation`. - * - * @example `'bip44:srp'`, `'bip44:ledger'`, `'raw:private-key'`, `'raw:mpc'` - */ -export type EntropyType = `${EntropyCategory}:${string}`; - -/** - * Unique identifier for an entropy source. - */ -export type EntropyId = string; - -/** - * Metadata associated with an entropy source. - * - * Currently a placeholder — fields will be added as requirements emerge. - */ -export type EntropyMetadata = Record; - /** * Describes the shape of the state object for {@link EntropyController}. */ diff --git a/packages/entropy-controller/src/index.ts b/packages/entropy-controller/src/index.ts index 3b144ac6ef6..6c9b590df22 100644 --- a/packages/entropy-controller/src/index.ts +++ b/packages/entropy-controller/src/index.ts @@ -1,13 +1,16 @@ export { EntropyController } from './entropy-controller'; export type { - EntropyCategory, EntropyControllerActions, EntropyControllerEvents, EntropyControllerGetStateAction, EntropyControllerMessenger, EntropyControllerState, EntropyControllerStateChangeEvent, +} from './entropy-controller'; +export type { + EntropyCategory, EntropyId, EntropyMetadata, EntropyType, -} from './entropy-controller'; +} from './types'; +export { toEntropyFingerprint, toEntropyId } from './utils'; diff --git a/packages/entropy-controller/src/types.ts b/packages/entropy-controller/src/types.ts new file mode 100644 index 00000000000..4d50a4f8e05 --- /dev/null +++ b/packages/entropy-controller/src/types.ts @@ -0,0 +1,28 @@ +/** + * The category of an entropy source. + * + * - `'bip44'` — Entropy that uses BIP-44 to derive signers (e.g. SRP, hardware + * wallets). + * - `'raw'` — Entropy that exposes a single key directly, without derivation + * (e.g. imported private keys, MPC). + */ +export type EntropyCategory = 'bip44' | 'raw'; + +/** + * The type of an entropy source, expressed as `category:implementation`. + * + * @example `'bip44:srp'`, `'bip44:ledger'`, `'raw:private-key'`, `'raw:mpc'` + */ +export type EntropyType = `${EntropyCategory}:${string}`; + +/** + * Unique identifier for an entropy source. + */ +export type EntropyId = string; + +/** + * Metadata associated with an entropy source. + * + * Currently a placeholder — fields will be added as requirements emerge. + */ +export type EntropyMetadata = Record; diff --git a/packages/entropy-controller/src/utils.test.ts b/packages/entropy-controller/src/utils.test.ts new file mode 100644 index 00000000000..54cfffabf0c --- /dev/null +++ b/packages/entropy-controller/src/utils.test.ts @@ -0,0 +1,75 @@ +import { toEntropyFingerprint, toEntropyId } from './utils'; + +const SECRET = new Uint8Array(32).fill(1); + +describe('toEntropyFingerprint', () => { + it('returns a 64-character lowercase hex string', async () => { + const fingerprint = await toEntropyFingerprint(SECRET, 'bip44:srp'); + expect(fingerprint).toHaveLength(64); + expect(fingerprint).toMatch(/^[0-9a-f]+$/u); + }); + + it('is deterministic — same inputs always produce the same fingerprint', async () => { + const first = await toEntropyFingerprint(SECRET, 'bip44:srp'); + const second = await toEntropyFingerprint(SECRET, 'bip44:srp'); + expect(first).toBe(second); + }); + + it('is type-scoped — different entropy types produce different fingerprints for the same secret', async () => { + const srpFingerprint = await toEntropyFingerprint(SECRET, 'bip44:srp'); + const pkFingerprint = await toEntropyFingerprint( + SECRET, + 'raw:private-key', + ); + expect(srpFingerprint).not.toBe(pkFingerprint); + }); + + it('produces different fingerprints for different secrets of the same type', async () => { + const otherSecret = new Uint8Array(32).fill(2); + const first = await toEntropyFingerprint(SECRET, 'bip44:srp'); + const second = await toEntropyFingerprint(otherSecret, 'bip44:srp'); + expect(first).not.toBe(second); + }); + + it('matches a known value to guard against accidental algorithm changes', async () => { + // hex( HMAC-SHA256( key=Uint8Array(32).fill(1), msg='metamask:bip44:srp:fingerprint' ) ) + const fingerprint = await toEntropyFingerprint(SECRET, 'bip44:srp'); + expect(fingerprint).toBe( + '10d37bf4886303a200fe2e8147ded8e89458e37576a799ddaaacb33c89c07599', + ); + }); +}); + +describe('toEntropyId', () => { + it('returns a valid UUID v4 string', async () => { + const id = await toEntropyId(SECRET, 'bip44:srp'); + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ); + }); + + it('is deterministic — same inputs always produce the same ID', async () => { + const first = await toEntropyId(SECRET, 'bip44:srp'); + const second = await toEntropyId(SECRET, 'bip44:srp'); + expect(first).toBe(second); + }); + + it('is type-scoped — different entropy types produce different IDs for the same secret', async () => { + const srpId = await toEntropyId(SECRET, 'bip44:srp'); + const pkId = await toEntropyId(SECRET, 'raw:private-key'); + expect(srpId).not.toBe(pkId); + }); + + it('produces different IDs for different secrets of the same type', async () => { + const otherSecret = new Uint8Array(32).fill(2); + const first = await toEntropyId(SECRET, 'bip44:srp'); + const second = await toEntropyId(otherSecret, 'bip44:srp'); + expect(first).not.toBe(second); + }); + + it('matches a known value to guard against accidental algorithm changes', async () => { + // uuid({ random: HMAC-SHA256( key=Uint8Array(32).fill(1), msg='metamask:bip44:srp:fingerprint' ).slice(0, 16) }) + const id = await toEntropyId(SECRET, 'bip44:srp'); + expect(id).toBe('10d37bf4-8863-43a2-80fe-2e8147ded8e8'); + }); +}); diff --git a/packages/entropy-controller/src/utils.ts b/packages/entropy-controller/src/utils.ts new file mode 100644 index 00000000000..57bed674106 --- /dev/null +++ b/packages/entropy-controller/src/utils.ts @@ -0,0 +1,75 @@ +import { hmac } from '@noble/hashes/hmac'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex } from '@noble/hashes/utils'; +import { v4 as uuid } from 'uuid'; + +import type { EntropyId, EntropyType } from './types'; + +/** + * Computes the raw HMAC-SHA256 digest for a piece of entropy — the shared + * primitive underlying both {@link toEntropyFingerprint} and + * {@link toEntropyId}. + * + * Using the secret as the HMAC key and the `EntropyType` as the domain + * separator ensures non-reversibility and type-scoping. + * + * @param secret - The raw entropy bytes. + * @param entropyType - The type of entropy source. + * @returns The 32-byte HMAC-SHA256 digest. + */ +async function toEntropyFingerprintBytes( + secret: Uint8Array, + entropyType: EntropyType, +): Promise { + const message = new TextEncoder().encode( + `metamask:${entropyType}:fingerprint`, + ); + return hmac(sha256, secret, message); +} + +/** + * Computes a deterministic, non-reversible hex fingerprint for a piece of + * entropy. + * + * The fingerprint is `hex(HMAC-SHA256(key=secret, msg='metamask:{entropyType}:fingerprint'))`. + * It is suitable for comparison and auditing — e.g. detecting that two + * separate components hold the same underlying secret. + * + * The return type is `Promise` to allow a future migration to the Web + * Crypto API without breaking callers. + * + * @param secret - The raw entropy bytes (e.g. BIP-39 mnemonic bytes, a 32-byte + * private key). + * @param entropyType - The type of entropy source, expressed as + * `category:implementation` (e.g. `'bip44:srp'`, `'raw:private-key'`). + * @returns The lowercase hex-encoded 32-byte HMAC-SHA256 digest. + */ +export async function toEntropyFingerprint( + secret: Uint8Array, + entropyType: EntropyType, +): Promise { + return bytesToHex(await toEntropyFingerprintBytes(secret, entropyType)); +} + +/** + * Derives a deterministic UUID v4 to use as an {@link EntropyId}. + * + * The UUID is built from the first 16 bytes of + * `HMAC-SHA256(key=secret, msg='metamask:{entropyType}:fingerprint')`, + * formatted as a standard UUID v4 (with the version and variant bits fixed by + * the UUID spec). Same secret + same type always produces the same ID. + * + * @param secret - The raw entropy bytes (e.g. BIP-39 mnemonic bytes, a 32-byte + * private key). + * @param entropyType - The type of entropy source, expressed as + * `category:implementation` (e.g. `'bip44:srp'`, `'raw:private-key'`). + * @returns A deterministic UUID v4 string suitable for use as an + * {@link EntropyId}. + */ +export async function toEntropyId( + secret: Uint8Array, + entropyType: EntropyType, +): Promise { + const bytes = await toEntropyFingerprintBytes(secret, entropyType); + return uuid({ random: bytes.slice(0, 16) }); +} diff --git a/yarn.lock b/yarn.lock index d8c3167a78f..dfbe6b8f4b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6600,6 +6600,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/messenger": "npm:^2.0.0" + "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" @@ -6609,6 +6610,7 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" + uuid: "npm:^8.3.2" languageName: unknown linkType: soft From 46ec1328f31f4dec990c3a48300480e5bb23a9c0 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 16 Jul 2026 13:49:41 +0200 Subject: [PATCH 05/19] fix: fix yarn constraints --- packages/entropy-controller/package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/entropy-controller/package.json b/packages/entropy-controller/package.json index d012e2b0a60..456d26625cc 100644 --- a/packages/entropy-controller/package.json +++ b/packages/entropy-controller/package.json @@ -50,7 +50,9 @@ "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" + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix" }, "dependencies": { "@metamask/base-controller": "^9.1.0", From f20c68d430eaf580ac7a0445459bad807b479c2f Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 16 Jul 2026 13:51:46 +0200 Subject: [PATCH 06/19] chore: fix changelog --- packages/entropy-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/entropy-controller/CHANGELOG.md b/packages/entropy-controller/CHANGELOG.md index 366bd0dca54..53b7c5c0184 100644 --- a/packages/entropy-controller/CHANGELOG.md +++ b/packages/entropy-controller/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## Added +### Added - Add `EntropyController` ([#9538](https://github.com/MetaMask/core/pull/9538)) From 41f53d858b52e6d5b9d072d209fe84b8806a6d76 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 16 Jul 2026 17:05:13 +0200 Subject: [PATCH 07/19] feat: add syncEntropies --- packages/entropy-controller/package.json | 3 + .../src/entropy-controller.test.ts | 460 +++++++++++++----- .../src/entropy-controller.ts | 108 +++- packages/entropy-controller/src/index.ts | 2 +- packages/entropy-controller/src/utils.test.ts | 21 +- packages/entropy-controller/src/utils.ts | 16 + .../entropy-controller/tsconfig.build.json | 3 + packages/entropy-controller/tsconfig.json | 3 + yarn.lock | 3 + 9 files changed, 486 insertions(+), 133 deletions(-) diff --git a/packages/entropy-controller/package.json b/packages/entropy-controller/package.json index 456d26625cc..e1cdee72bce 100644 --- a/packages/entropy-controller/package.json +++ b/packages/entropy-controller/package.json @@ -56,7 +56,10 @@ }, "dependencies": { "@metamask/base-controller": "^9.1.0", + "@metamask/eth-hd-keyring": "^14.1.1", + "@metamask/keyring-controller": "^27.1.0", "@metamask/messenger": "^2.0.0", + "@metamask/utils": "^11.11.0", "@noble/hashes": "^1.8.0", "uuid": "^8.3.2" }, diff --git a/packages/entropy-controller/src/entropy-controller.test.ts b/packages/entropy-controller/src/entropy-controller.test.ts index 3ddd6c24440..3904e78238b 100644 --- a/packages/entropy-controller/src/entropy-controller.test.ts +++ b/packages/entropy-controller/src/entropy-controller.test.ts @@ -8,10 +8,147 @@ import type { import type { EntropyControllerMessenger } from './entropy-controller'; import { EntropyController } from './entropy-controller'; +import { toEntropyId } from './utils'; + +// Stable test secrets +const HD_MNEMONIC = new TextEncoder().encode( + 'test test test test test test test test test test test junk', +); +const PRIVATE_KEY_HEX = + '4af1bceebf7f3634ec3cff8a2c38e51178d5d4ce585c52d6043e5e2cc3f1d3e1'; +const PRIVATE_KEY_BYTES = Uint8Array.from( + PRIVATE_KEY_HEX.match(/../gu)!.map((b) => parseInt(b, 16)), +); + +type KeyringStub = { + type: string; + metadata: { id: string; name: string }; + /** HD keyring: mnemonic bytes, or null if not yet initialised. */ + mnemonic?: Uint8Array | null; + /** Accounts held by this keyring. `id` and `privateKey` are only relevant for Simple keyrings. */ + accounts: { address: string; id?: string; privateKey?: string }[]; +}; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +type Mocks = { + // eslint-disable-next-line @typescript-eslint/naming-convention + KeyringController: { + getState: jest.Mock; + withKeyringV2Unsafe: jest.Mock; + }; +}; + +async function setup({ + keyrings = [], + options = {}, +}: { + keyrings?: KeyringStub[]; + options?: Partial[0]>; +} = {}): Promise<{ + controller: EntropyController; + rootMessenger: RootMessenger; + messenger: EntropyControllerMessenger; + mocks: Mocks; +}> { + const mocks: Mocks = { + KeyringController: { + getState: jest.fn(), + withKeyringV2Unsafe: jest.fn(), + }, + }; + + mocks.KeyringController.getState.mockImplementation( + () => ({ + keyrings: keyrings.map((k) => ({ + type: k.type, + accounts: k.accounts.map((a) => a.address), + metadata: k.metadata, + })), + isUnlocked: true, + }) as never, + ); + + mocks.KeyringController.withKeyringV2Unsafe.mockImplementation( + async ( + selector: { id: string }, + operation: (payload: { + keyring: unknown; + metadata: unknown; + }) => Promise, + ) => { + const stub = keyrings.find((k) => k.metadata.id === selector.id); + return operation({ + keyring: { + mnemonic: stub?.mnemonic ?? null, + getAccounts: async () => + (stub?.accounts ?? []) + .filter((a) => a.id !== undefined) + .map(({ id }) => ({ id })), + exportAccount: async (accountId: string) => { + const account = stub?.accounts.find((a) => a.id === accountId); + return { + type: 'private-key', + encoding: 'hexadecimal', + privateKey: account?.privateKey ?? '', + }; + }, + }, + metadata: { id: selector.id, name: '' }, + }); + }, + ); + + const rootMessenger = new Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + rootMessenger.registerActionHandler( + 'KeyringController:getState', + mocks.KeyringController.getState, + ); + + rootMessenger.registerActionHandler( + 'KeyringController:withKeyringV2Unsafe', + mocks.KeyringController.withKeyringV2Unsafe, + ); + + const messenger = new Messenger< + 'EntropyController', + MessengerActions, + MessengerEvents + >({ + namespace: 'EntropyController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: [ + 'KeyringController:getState', + 'KeyringController:withKeyringV2Unsafe', + ], + events: [], + }); + + const controller = new EntropyController({ + messenger, + ...options, + }); + + return { controller, rootMessenger, messenger, mocks }; +} + describe('EntropyController', () => { describe('constructor', () => { - it('accepts initial state', () => { + it('accepts initial state', async () => { const givenState = { entropySources: { 'entropy-1': { @@ -21,160 +158,223 @@ describe('EntropyController', () => { }, }; - withController({ options: { state: givenState } }, ({ controller }) => { - expect(controller.state).toStrictEqual(givenState); + const { controller } = await setup({ options: { state: givenState } }); + + expect(controller.state).toStrictEqual(givenState); + }); + + it('fills in missing initial state with defaults', async () => { + const { controller } = await setup(); + + expect(controller.state).toMatchInlineSnapshot(` + { + "entropySources": {}, + } + `); + }); + }); + + describe('syncEntropies', () => { + it('populates entropySources from an HD keyring', async () => { + const keyringId = 'hd-keyring-id'; + const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); + + const { controller } = await setup({ + keyrings: [ + { + type: 'HD Key Tree', + metadata: { id: keyringId, name: '' }, + mnemonic: HD_MNEMONIC, + accounts: [{ address: '0xabc' }], + }, + ], + }); + + await controller.syncEntropies(); + + expect(controller.state.entropySources).toStrictEqual({ + [expectedId]: { type: 'bip44:srp', metadata: {} }, }); }); - it('fills in missing initial state with defaults', () => { - withController(({ controller }) => { - expect(controller.state).toMatchInlineSnapshot(` + it('populates entropySources from a Simple keyring', async () => { + const keyringId = 'simple-keyring-id'; + const accountId = 'account-uuid-1'; + const expectedId = await toEntropyId(PRIVATE_KEY_BYTES, 'raw:private-key'); + + const { controller } = await setup({ + keyrings: [ { - "entropySources": {}, - } - `); + type: 'Simple Key Pair', + metadata: { id: keyringId, name: '' }, + accounts: [{ address: '0xdef', id: accountId, privateKey: PRIVATE_KEY_HEX }], + }, + ], + }); + + await controller.syncEntropies(); + + expect(controller.state.entropySources).toStrictEqual({ + [expectedId]: { type: 'raw:private-key', metadata: {} }, }); }); - }); - describe('metadata', () => { - it('does not include entropy sources in debug snapshots', () => { - withController(({ controller }) => { - expect( - deriveStateFromMetadata( - controller.state, - controller.metadata, - 'includeInDebugSnapshot', - ), - ).toMatchInlineSnapshot(`{}`); + it('handles a Simple keyring with multiple accounts', async () => { + const keyringId = 'simple-keyring-id'; + const secondKeyHex = + '1111111111111111111111111111111111111111111111111111111111111111'; + const secondKeyBytes = Uint8Array.from( + secondKeyHex.match(/../gu)!.map((b) => parseInt(b, 16)), + ); + const expectedId1 = await toEntropyId(PRIVATE_KEY_BYTES, 'raw:private-key'); + const expectedId2 = await toEntropyId(secondKeyBytes, 'raw:private-key'); + + const { controller } = await setup({ + keyrings: [ + { + type: 'Simple Key Pair', + metadata: { id: keyringId, name: '' }, + accounts: [ + { address: '0xaaa', id: 'account-1', privateKey: PRIVATE_KEY_HEX }, + { address: '0xbbb', id: 'account-2', privateKey: secondKeyHex }, + ], + }, + ], + }); + + await controller.syncEntropies(); + + expect(controller.state.entropySources).toStrictEqual({ + [expectedId1]: { type: 'raw:private-key', metadata: {} }, + [expectedId2]: { type: 'raw:private-key', metadata: {} }, }); }); - it('includes entropy sources in state logs', () => { - withController(({ controller }) => { - expect( - deriveStateFromMetadata( - controller.state, - controller.metadata, - 'includeInStateLogs', - ), - ).toMatchInlineSnapshot(` + it('ignores keyrings that do not own entropy', async () => { + const { controller } = await setup({ + keyrings: [ { - "entropySources": {}, - } - `); + type: 'Snap Keyring', + metadata: { id: 'snap-id', name: '' }, + accounts: [{ address: '0xabc' }], + }, + ], }); + + await controller.syncEntropies(); + + expect(controller.state.entropySources).toStrictEqual({}); }); - it('persists entropy sources', () => { - withController(({ controller }) => { - expect( - deriveStateFromMetadata( - controller.state, - controller.metadata, - 'persist', - ), - ).toMatchInlineSnapshot(` + it('skips an HD keyring whose mnemonic is not yet initialised', async () => { + const keyringId = 'hd-keyring-id'; + + const { controller } = await setup({ + keyrings: [ { - "entropySources": {}, - } - `); + type: 'HD Key Tree', + metadata: { id: keyringId, name: '' }, + mnemonic: null, + accounts: [], + }, + ], }); + + await controller.syncEntropies(); + + expect(controller.state.entropySources).toStrictEqual({}); }); - it('exposes entropy sources to UI', () => { - withController(({ controller }) => { - expect( - deriveStateFromMetadata( - controller.state, - controller.metadata, - 'usedInUi', - ), - ).toMatchInlineSnapshot(` + it('replaces the entire entropySources map on each call', async () => { + const keyringId = 'hd-keyring-id'; + const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); + + const { controller } = await setup({ + options: { + state: { + entropySources: { + 'stale-id': { type: 'bip44:srp', metadata: {} }, + }, + }, + }, + keyrings: [ { - "entropySources": {}, - } - `); + type: 'HD Key Tree', + metadata: { id: keyringId, name: '' }, + mnemonic: HD_MNEMONIC, + accounts: [{ address: '0xabc' }], + }, + ], + }); + + await controller.syncEntropies(); + + expect(controller.state.entropySources).toStrictEqual({ + [expectedId]: { type: 'bip44:srp', metadata: {} }, }); + expect(controller.state.entropySources['stale-id']).toBeUndefined(); }); }); -}); -/** - * The type of the messenger populated with all external actions and events - * required by the controller under test. - */ -type RootMessenger = Messenger< - MockAnyNamespace, - MessengerActions, - MessengerEvents ->; + describe('metadata', () => { + it('does not include entropy sources in debug snapshots', async () => { + const { controller } = await setup(); -/** - * The callback that `withController` calls. - */ -type WithControllerCallback = (payload: { - controller: EntropyController; - rootMessenger: RootMessenger; - controllerMessenger: EntropyControllerMessenger; -}) => ReturnValue; - -/** - * The options that `withController` takes. - */ -type WithControllerOptions = { - options: Partial[0]>; -}; + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); -/** - * Constructs the messenger populated with all external actions and events - * required by the controller under test. - * - * @returns The root messenger. - */ -function getRootMessenger(): RootMessenger { - return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); -} + it('includes entropy sources in state logs', async () => { + const { controller } = await setup(); -/** - * Constructs the messenger for the controller under test. - * - * @param rootMessenger - The root messenger, with all external actions and - * events required by the controller's messenger. - * @returns The controller-specific messenger. - */ -function getMessenger( - rootMessenger: RootMessenger, -): EntropyControllerMessenger { - return new Messenger({ - namespace: 'EntropyController', - parent: rootMessenger, - }); -} + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "entropySources": {}, + } + `); + }); -/** - * Wrap tests for the controller under test by ensuring that the controller is - * created ahead of time with the given options. - * - * @param args - Either a function, or an options bag + a function. The options - * bag contains arguments for the controller constructor. All constructor - * arguments are optional and will be filled in with defaults as needed - * (including `messenger`). The function is called with the instantiated - * controller, root messenger, and controller messenger. - * @returns The same return value as the given function. - */ -function withController( - ...args: - | [WithControllerCallback] - | [WithControllerOptions, WithControllerCallback] -): ReturnValue { - const [{ options = {} }, testFunction] = - args.length === 2 ? args : [{}, args[0]]; - const rootMessenger = getRootMessenger(); - const controllerMessenger = getMessenger(rootMessenger); - const controller = new EntropyController({ - messenger: controllerMessenger, - ...options, + it('persists entropy sources', async () => { + const { controller } = await setup(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "entropySources": {}, + } + `); + }); + + it('exposes entropy sources to UI', async () => { + const { controller } = await setup(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "entropySources": {}, + } + `); + }); }); - return testFunction({ controller, rootMessenger, controllerMessenger }); -} +}); diff --git a/packages/entropy-controller/src/entropy-controller.ts b/packages/entropy-controller/src/entropy-controller.ts index 3b4e504c11d..9eb7d815854 100644 --- a/packages/entropy-controller/src/entropy-controller.ts +++ b/packages/entropy-controller/src/entropy-controller.ts @@ -4,9 +4,18 @@ import type { StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; +import type { HdKeyring } from '@metamask/eth-hd-keyring/v2'; +import type { SimpleKeyring } from '@metamask/eth-simple-keyring/v2'; +import type { + KeyringControllerGetStateAction, + KeyringControllerWithKeyringV2UnsafeAction, +} from '@metamask/keyring-controller'; +import { KeyringTypes } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; +import { hexToBytes } from '@metamask/utils'; import type { EntropyId, EntropyMetadata, EntropyType } from './types'; +import { isKeyringOwningEntropy, toEntropyId } from './utils'; /** * The name of the {@link EntropyController}, used to namespace the @@ -81,7 +90,9 @@ export type EntropyControllerActions = EntropyControllerGetStateAction; /** * Actions from other messengers that {@link EntropyControllerMessenger} calls. */ -type AllowedActions = never; +type AllowedActions = + | KeyringControllerGetStateAction + | KeyringControllerWithKeyringV2UnsafeAction; /** * Published when the state of {@link EntropyController} changes. @@ -152,4 +163,99 @@ export class EntropyController extends BaseController< }, }); } + + /** + * Scans the keyrings managed by `KeyringController` and rebuilds the + * `entropySources` registry from scratch. + * + * Only keyrings that own entropy are considered (see + * {@link isKeyringOwningEntropy}). This method replaces the entire + * `entropySources` map in a single state update — stale entries are + * automatically removed. + */ + async syncEntropies(): Promise { + const { keyrings } = this.messenger.call('KeyringController:getState'); + const entropyKeyrings = keyrings.filter(isKeyringOwningEntropy); + + const newSources: EntropyControllerState['entropySources'] = {}; + + for (const keyringObj of entropyKeyrings) { + const { id } = keyringObj.metadata; + + if (keyringObj.type === KeyringTypes.hd) { + await this.#syncHdKeyring(id, newSources); + } else if (keyringObj.type === KeyringTypes.simple) { + await this.#syncSimpleKeyring(id, newSources); + } + } + + this.update((state) => { + state.entropySources = newSources; + }); + } + + /** + * Derives the entropy source entry for a single HD keyring and adds it to + * the given map. + * + * Reads the mnemonic via `withKeyringV2Unsafe` and derives a stable + * `EntropyId` with type `'bip44:srp'`. Skips the keyring silently if the + * mnemonic is not yet initialised. + * + * @param id - The keyring metadata ID. + * @param sources - The map to populate in-place. + */ + async #syncHdKeyring( + id: string, + sources: EntropyControllerState['entropySources'], + ): Promise { + await this.messenger.call( + 'KeyringController:withKeyringV2Unsafe', + { id }, + async ({ keyring }) => { + const hdKeyring = keyring as HdKeyring; + if (!hdKeyring.mnemonic) { + return; + } + const entropyId = await toEntropyId(hdKeyring.mnemonic, 'bip44:srp'); + sources[entropyId] = { type: 'bip44:srp', metadata: {} }; + }, + ); + } + + /** + * Derives entropy source entries for a single Simple keyring and adds them + * to the given map. + * + * Enumerates accounts via `withKeyringV2Unsafe`, exports each private key + * via `exportAccount`, and derives a stable `EntropyId` with type + * `'raw:private-key'` for each one. + * + * @param id - The keyring metadata ID. + * @param sources - The map to populate in-place. + */ + async #syncSimpleKeyring( + id: string, + sources: EntropyControllerState['entropySources'], + ): Promise { + await this.messenger.call( + 'KeyringController:withKeyringV2Unsafe', + { id }, + async ({ keyring }) => { + const simpleKeyring = keyring as SimpleKeyring; + const accounts = await simpleKeyring.getAccounts(); + for (const account of accounts) { + const exported = await simpleKeyring.exportAccount(account.id, { + type: 'private-key', + encoding: 'hexadecimal', + }); + const entropyId = await toEntropyId( + hexToBytes(exported.privateKey), + 'raw:private-key', + ); + sources[entropyId] = { type: 'raw:private-key', metadata: {} }; + } + }, + ); + } } diff --git a/packages/entropy-controller/src/index.ts b/packages/entropy-controller/src/index.ts index 6c9b590df22..8dee35af6f4 100644 --- a/packages/entropy-controller/src/index.ts +++ b/packages/entropy-controller/src/index.ts @@ -13,4 +13,4 @@ export type { EntropyMetadata, EntropyType, } from './types'; -export { toEntropyFingerprint, toEntropyId } from './utils'; +export { isKeyringOwningEntropy, toEntropyFingerprint, toEntropyId } from './utils'; diff --git a/packages/entropy-controller/src/utils.test.ts b/packages/entropy-controller/src/utils.test.ts index 54cfffabf0c..6435fb6659c 100644 --- a/packages/entropy-controller/src/utils.test.ts +++ b/packages/entropy-controller/src/utils.test.ts @@ -1,7 +1,26 @@ -import { toEntropyFingerprint, toEntropyId } from './utils'; +import type { KeyringObject } from '@metamask/keyring-controller'; + +import { isKeyringOwningEntropy, toEntropyFingerprint, toEntropyId } from './utils'; const SECRET = new Uint8Array(32).fill(1); +describe('isKeyringOwningEntropy', () => { + it.each([ + { type: 'HD Key Tree', expected: true }, + { type: 'Simple Key Pair', expected: true }, + { type: 'Snap Keyring', expected: false }, + { type: 'Ledger Hardware', expected: false }, + { type: 'Trezor Hardware', expected: false }, + ])('returns $expected for type "$type"', ({ type, expected }) => { + const keyring = { + type, + accounts: [], + metadata: { id: 'test-id', name: '' }, + } as KeyringObject; + expect(isKeyringOwningEntropy(keyring)).toBe(expected); + }); +}); + describe('toEntropyFingerprint', () => { it('returns a 64-character lowercase hex string', async () => { const fingerprint = await toEntropyFingerprint(SECRET, 'bip44:srp'); diff --git a/packages/entropy-controller/src/utils.ts b/packages/entropy-controller/src/utils.ts index 57bed674106..becbf4452a2 100644 --- a/packages/entropy-controller/src/utils.ts +++ b/packages/entropy-controller/src/utils.ts @@ -1,3 +1,5 @@ +import type { KeyringObject } from '@metamask/keyring-controller'; +import { KeyringTypes } from '@metamask/keyring-controller'; import { hmac } from '@noble/hashes/hmac'; import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex } from '@noble/hashes/utils'; @@ -5,6 +7,20 @@ import { v4 as uuid } from 'uuid'; import type { EntropyId, EntropyType } from './types'; +/** + * Checks whether a keyring is a source of entropy — i.e. whether it owns + * secret material that the {@link EntropyController} should track. + * + * Currently matches HD keyrings (`'HD Key Tree'`, which own a BIP-39 mnemonic) + * and Simple keyrings (`'Simple Key Pair'`, which own imported private keys). + * + * @param keyring - The keyring object from `KeyringController` state. + * @returns `true` if the keyring owns entropy, `false` otherwise. + */ +export function isKeyringOwningEntropy(keyring: KeyringObject): boolean { + return keyring.type === KeyringTypes.hd || keyring.type === KeyringTypes.simple; +} + /** * Computes the raw HMAC-SHA256 digest for a piece of entropy — the shared * primitive underlying both {@link toEntropyFingerprint} and diff --git a/packages/entropy-controller/tsconfig.build.json b/packages/entropy-controller/tsconfig.build.json index 249f327913d..fb01dfc7b16 100644 --- a/packages/entropy-controller/tsconfig.build.json +++ b/packages/entropy-controller/tsconfig.build.json @@ -9,6 +9,9 @@ { "path": "../base-controller/tsconfig.build.json" }, + { + "path": "../keyring-controller/tsconfig.build.json" + }, { "path": "../messenger/tsconfig.build.json" } diff --git a/packages/entropy-controller/tsconfig.json b/packages/entropy-controller/tsconfig.json index cb296895b28..11613f15d5b 100644 --- a/packages/entropy-controller/tsconfig.json +++ b/packages/entropy-controller/tsconfig.json @@ -7,6 +7,9 @@ { "path": "../base-controller" }, + { + "path": "../keyring-controller" + }, { "path": "../messenger" } diff --git a/yarn.lock b/yarn.lock index dfbe6b8f4b0..864064886f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6599,7 +6599,10 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" + "@metamask/eth-hd-keyring": "npm:^14.1.1" + "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/messenger": "npm:^2.0.0" + "@metamask/utils": "npm:^11.11.0" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" From 68626d3deb12b9cf7019815698ed54eaa597d009 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 16 Jul 2026 18:00:56 +0200 Subject: [PATCH 08/19] feat: trigger sync on :unlock --- .../src/entropy-controller.test.ts | 51 ++++++++++++++++++- .../src/entropy-controller.ts | 15 +++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/entropy-controller/src/entropy-controller.test.ts b/packages/entropy-controller/src/entropy-controller.test.ts index 3904e78238b..0dcb253d045 100644 --- a/packages/entropy-controller/src/entropy-controller.test.ts +++ b/packages/entropy-controller/src/entropy-controller.test.ts @@ -134,7 +134,7 @@ async function setup({ 'KeyringController:getState', 'KeyringController:withKeyringV2Unsafe', ], - events: [], + events: ['KeyringController:unlock'], }); const controller = new EntropyController({ @@ -314,6 +314,55 @@ describe('EntropyController', () => { }); expect(controller.state.entropySources['stale-id']).toBeUndefined(); }); + + it('does nothing when the keyring is locked', async () => { + const keyringId = 'hd-keyring-id'; + + const { controller, mocks } = await setup({ + keyrings: [ + { + type: 'HD Key Tree', + metadata: { id: keyringId, name: '' }, + mnemonic: HD_MNEMONIC, + accounts: [{ address: '0xabc' }], + }, + ], + }); + + mocks.KeyringController.getState.mockReturnValueOnce({ + keyrings: [], + isUnlocked: false, + }); + + await controller.syncEntropies(); + + expect(controller.state.entropySources).toStrictEqual({}); + }); + + it('synchronizes automatically when KeyringController emits unlock', async () => { + const keyringId = 'hd-keyring-id'; + const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); + + const { controller, rootMessenger } = await setup({ + keyrings: [ + { + type: 'HD Key Tree', + metadata: { id: keyringId, name: '' }, + mnemonic: HD_MNEMONIC, + accounts: [{ address: '0xabc' }], + }, + ], + }); + + // Spy before publishing so we can await the async sync triggered by the event. + const syncSpy = jest.spyOn(controller, 'syncEntropies'); + rootMessenger.publish('KeyringController:unlock'); + await syncSpy.mock.results[0]?.value; + + expect(controller.state.entropySources).toStrictEqual({ + [expectedId]: { type: 'bip44:srp', metadata: {} }, + }); + }); }); describe('metadata', () => { diff --git a/packages/entropy-controller/src/entropy-controller.ts b/packages/entropy-controller/src/entropy-controller.ts index 9eb7d815854..050aee9d480 100644 --- a/packages/entropy-controller/src/entropy-controller.ts +++ b/packages/entropy-controller/src/entropy-controller.ts @@ -8,6 +8,7 @@ import type { HdKeyring } from '@metamask/eth-hd-keyring/v2'; import type { SimpleKeyring } from '@metamask/eth-simple-keyring/v2'; import type { KeyringControllerGetStateAction, + KeyringControllerUnlockEvent, KeyringControllerWithKeyringV2UnsafeAction, } from '@metamask/keyring-controller'; import { KeyringTypes } from '@metamask/keyring-controller'; @@ -111,7 +112,7 @@ export type EntropyControllerEvents = EntropyControllerStateChangeEvent; * Events from other messengers that {@link EntropyControllerMessenger} * subscribes to. */ -type AllowedEvents = never; +type AllowedEvents = KeyringControllerUnlockEvent; /** * The messenger restricted to actions and events accessed by @@ -162,6 +163,10 @@ export class EntropyController extends BaseController< ...state, }, }); + + this.messenger.subscribe('KeyringController:unlock', () => { + this.syncEntropies().catch(console.error); + }); } /** @@ -174,7 +179,13 @@ export class EntropyController extends BaseController< * automatically removed. */ async syncEntropies(): Promise { - const { keyrings } = this.messenger.call('KeyringController:getState'); + const { isUnlocked, keyrings } = this.messenger.call( + 'KeyringController:getState', + ); + if (!isUnlocked) { + return; + } + const entropyKeyrings = keyrings.filter(isKeyringOwningEntropy); const newSources: EntropyControllerState['entropySources'] = {}; From 1bbbee1e66a6409052e0e648fcdbb395c814b2be Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 20 Jul 2026 11:54:37 +0200 Subject: [PATCH 09/19] feat(keyring-controller): add granular keyring added/removed events --- .../src/entropy-controller.test.ts | 166 +++++++++++++++++- .../src/entropy-controller.ts | 64 ++++++- packages/entropy-controller/src/types.ts | 9 +- .../src/KeyringController.ts | 70 +++++++- 4 files changed, 292 insertions(+), 17 deletions(-) diff --git a/packages/entropy-controller/src/entropy-controller.test.ts b/packages/entropy-controller/src/entropy-controller.test.ts index 0dcb253d045..99e99c24db0 100644 --- a/packages/entropy-controller/src/entropy-controller.test.ts +++ b/packages/entropy-controller/src/entropy-controller.test.ts @@ -134,7 +134,11 @@ async function setup({ 'KeyringController:getState', 'KeyringController:withKeyringV2Unsafe', ], - events: ['KeyringController:unlock'], + events: [ + 'KeyringController:unlock', + 'KeyringController:keyringAdded', + 'KeyringController:keyringRemoved', + ], }); const controller = new EntropyController({ @@ -193,7 +197,10 @@ describe('EntropyController', () => { await controller.syncEntropies(); expect(controller.state.entropySources).toStrictEqual({ - [expectedId]: { type: 'bip44:srp', metadata: {} }, + [expectedId]: { + type: 'bip44:srp', + metadata: { legacyEntropySource: keyringId }, + }, }); }); @@ -215,7 +222,10 @@ describe('EntropyController', () => { await controller.syncEntropies(); expect(controller.state.entropySources).toStrictEqual({ - [expectedId]: { type: 'raw:private-key', metadata: {} }, + [expectedId]: { + type: 'raw:private-key', + metadata: { legacyEntropySource: keyringId }, + }, }); }); @@ -245,8 +255,14 @@ describe('EntropyController', () => { await controller.syncEntropies(); expect(controller.state.entropySources).toStrictEqual({ - [expectedId1]: { type: 'raw:private-key', metadata: {} }, - [expectedId2]: { type: 'raw:private-key', metadata: {} }, + [expectedId1]: { + type: 'raw:private-key', + metadata: { legacyEntropySource: keyringId }, + }, + [expectedId2]: { + type: 'raw:private-key', + metadata: { legacyEntropySource: keyringId }, + }, }); }); @@ -293,7 +309,10 @@ describe('EntropyController', () => { options: { state: { entropySources: { - 'stale-id': { type: 'bip44:srp', metadata: {} }, + 'stale-id': { + type: 'bip44:srp', + metadata: { legacyEntropySource: 'old-keyring-id' }, + }, }, }, }, @@ -310,7 +329,10 @@ describe('EntropyController', () => { await controller.syncEntropies(); expect(controller.state.entropySources).toStrictEqual({ - [expectedId]: { type: 'bip44:srp', metadata: {} }, + [expectedId]: { + type: 'bip44:srp', + metadata: { legacyEntropySource: keyringId }, + }, }); expect(controller.state.entropySources['stale-id']).toBeUndefined(); }); @@ -360,8 +382,136 @@ describe('EntropyController', () => { await syncSpy.mock.results[0]?.value; expect(controller.state.entropySources).toStrictEqual({ - [expectedId]: { type: 'bip44:srp', metadata: {} }, + [expectedId]: { + type: 'bip44:srp', + metadata: { legacyEntropySource: keyringId }, + }, + }); + }); + }); + + describe('keyringAdded', () => { + it('merges the new HD keyring into entropySources', async () => { + const keyringId = 'new-hd-keyring-id'; + const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); + + const { controller, rootMessenger } = await setup({ + keyrings: [ + { + type: 'HD Key Tree', + metadata: { id: keyringId, name: '' }, + mnemonic: HD_MNEMONIC, + accounts: [{ address: '0xabc' }], + }, + ], + }); + + const syncSpy = jest.spyOn(controller, 'syncEntropies'); + + rootMessenger.publish('KeyringController:keyringAdded', { + type: 'HD Key Tree', + accounts: ['0xabc'], + metadata: { id: keyringId, name: '' }, }); + + // Wait for the async merge to complete + await new Promise(process.nextTick); + await new Promise(process.nextTick); + + expect(controller.state.entropySources[expectedId]).toStrictEqual({ + type: 'bip44:srp', + metadata: { legacyEntropySource: keyringId }, + }); + // syncEntropies should NOT have been called (incremental, not full rescan) + expect(syncSpy).not.toHaveBeenCalled(); + }); + + it('ignores keyrings that do not own entropy', async () => { + const { controller, rootMessenger } = await setup(); + + rootMessenger.publish('KeyringController:keyringAdded', { + type: 'Snap Keyring', + accounts: [], + metadata: { id: 'snap-id', name: '' }, + }); + + await new Promise(process.nextTick); + + expect(controller.state.entropySources).toStrictEqual({}); + }); + + it('does nothing when the vault is locked', async () => { + const keyringId = 'hd-keyring-id'; + const { controller, rootMessenger, mocks } = await setup({ + keyrings: [ + { + type: 'HD Key Tree', + metadata: { id: keyringId, name: '' }, + mnemonic: HD_MNEMONIC, + accounts: [{ address: '0xabc' }], + }, + ], + }); + + mocks.KeyringController.getState.mockReturnValueOnce({ + keyrings: [], + isUnlocked: false, + }); + + rootMessenger.publish('KeyringController:keyringAdded', { + type: 'HD Key Tree', + accounts: ['0xabc'], + metadata: { id: keyringId, name: '' }, + }); + + await new Promise(process.nextTick); + + expect(controller.state.entropySources).toStrictEqual({}); + }); + }); + + describe('keyringRemoved', () => { + it('removes all entropy sources belonging to the removed keyring', async () => { + const keyringId = 'hd-keyring-id'; + const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); + + const { controller, rootMessenger } = await setup({ + options: { + state: { + entropySources: { + [expectedId]: { + type: 'bip44:srp', + metadata: { legacyEntropySource: keyringId }, + }, + 'other-entropy-id': { + type: 'bip44:srp', + metadata: { legacyEntropySource: 'other-keyring-id' }, + }, + }, + }, + }, + }); + + rootMessenger.publish('KeyringController:keyringRemoved', { + id: keyringId, + name: '', + }); + + expect(controller.state.entropySources[expectedId]).toBeUndefined(); + expect( + controller.state.entropySources['other-entropy-id'], + ).toBeDefined(); + }); + + it('does nothing when no entropy sources match the removed keyring', async () => { + const { controller, rootMessenger } = await setup(); + + rootMessenger.publish('KeyringController:keyringRemoved', { + id: 'unknown-keyring-id', + name: '', + }); + + expect(controller.state.entropySources).toStrictEqual({}); }); }); diff --git a/packages/entropy-controller/src/entropy-controller.ts b/packages/entropy-controller/src/entropy-controller.ts index 050aee9d480..e6318edd191 100644 --- a/packages/entropy-controller/src/entropy-controller.ts +++ b/packages/entropy-controller/src/entropy-controller.ts @@ -8,6 +8,8 @@ import type { HdKeyring } from '@metamask/eth-hd-keyring/v2'; import type { SimpleKeyring } from '@metamask/eth-simple-keyring/v2'; import type { KeyringControllerGetStateAction, + KeyringControllerKeyringAddedEvent, + KeyringControllerKeyringRemovedEvent, KeyringControllerUnlockEvent, KeyringControllerWithKeyringV2UnsafeAction, } from '@metamask/keyring-controller'; @@ -112,7 +114,10 @@ export type EntropyControllerEvents = EntropyControllerStateChangeEvent; * Events from other messengers that {@link EntropyControllerMessenger} * subscribes to. */ -type AllowedEvents = KeyringControllerUnlockEvent; +type AllowedEvents = + | KeyringControllerUnlockEvent + | KeyringControllerKeyringAddedEvent + | KeyringControllerKeyringRemovedEvent; /** * The messenger restricted to actions and events accessed by @@ -167,6 +172,29 @@ export class EntropyController extends BaseController< this.messenger.subscribe('KeyringController:unlock', () => { this.syncEntropies().catch(console.error); }); + + this.messenger.subscribe('KeyringController:keyringAdded', (keyringObject) => { + if (!isKeyringOwningEntropy(keyringObject)) { + return; + } + const { isUnlocked } = this.messenger.call('KeyringController:getState'); + if (!isUnlocked) { + return; + } + this.#syncSingleKeyring(keyringObject.metadata.id, keyringObject.type).catch( + console.error, + ); + }); + + this.messenger.subscribe('KeyringController:keyringRemoved', ({ id }) => { + this.update((state) => { + for (const [entropyId, entry] of Object.entries(state.entropySources)) { + if (entry.metadata.legacyEntropySource === id) { + delete state.entropySources[entropyId]; + } + } + }); + }); } /** @@ -205,6 +233,30 @@ export class EntropyController extends BaseController< }); } + /** + * Derives entropy source entries for a single keyring and merges them into + * `state.entropySources`. + * + * Used by the `keyringAdded` event handler to incrementally update state + * without rescanning every keyring. + * + * @param id - The keyring metadata ID. + * @param type - The keyring type string. + */ + async #syncSingleKeyring(id: string, type: string): Promise { + const newEntries: EntropyControllerState['entropySources'] = {}; + + if (type === KeyringTypes.hd) { + await this.#syncHdKeyring(id, newEntries); + } else if (type === KeyringTypes.simple) { + await this.#syncSimpleKeyring(id, newEntries); + } + + this.update((state) => { + Object.assign(state.entropySources, newEntries); + }); + } + /** * Derives the entropy source entry for a single HD keyring and adds it to * the given map. @@ -229,7 +281,10 @@ export class EntropyController extends BaseController< return; } const entropyId = await toEntropyId(hdKeyring.mnemonic, 'bip44:srp'); - sources[entropyId] = { type: 'bip44:srp', metadata: {} }; + sources[entropyId] = { + type: 'bip44:srp', + metadata: { legacyEntropySource: id }, + }; }, ); } @@ -264,7 +319,10 @@ export class EntropyController extends BaseController< hexToBytes(exported.privateKey), 'raw:private-key', ); - sources[entropyId] = { type: 'raw:private-key', metadata: {} }; + sources[entropyId] = { + type: 'raw:private-key', + metadata: { legacyEntropySource: id }, + }; } }, ); diff --git a/packages/entropy-controller/src/types.ts b/packages/entropy-controller/src/types.ts index 4d50a4f8e05..68a7132d502 100644 --- a/packages/entropy-controller/src/types.ts +++ b/packages/entropy-controller/src/types.ts @@ -25,4 +25,11 @@ export type EntropyId = string; * * Currently a placeholder — fields will be added as requirements emerge. */ -export type EntropyMetadata = Record; +export type EntropyMetadata = { + /** + * The ID of the keyring that owns this entropy source. + * Prefixed `legacy` because keyrings are a transitional backing store — + * future entropy sources may not originate from a `KeyringController` keyring. + */ + legacyEntropySource: string; +}; diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index a16ce8802bf..e71e3b3d583 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -186,6 +186,16 @@ export type KeyringControllerUnlockEvent = { payload: []; }; +export type KeyringControllerKeyringAddedEvent = { + type: `${typeof name}:keyringAdded`; + payload: [KeyringObject]; +}; + +export type KeyringControllerKeyringRemovedEvent = { + type: `${typeof name}:keyringRemoved`; + payload: [KeyringMetadata]; +}; + export type KeyringControllerActions = | KeyringControllerGetStateAction | KeyringControllerMethodActions; @@ -194,7 +204,9 @@ export type KeyringControllerEvents = | KeyringControllerStateChangeEvent | KeyringControllerLockEvent | KeyringControllerUnlockEvent - | KeyringControllerAccountRemovedEvent; + | KeyringControllerAccountRemovedEvent + | KeyringControllerKeyringAddedEvent + | KeyringControllerKeyringRemovedEvent; export type KeyringControllerMessenger = Messenger< typeof name, @@ -1026,9 +1038,17 @@ export class KeyringController< ): Promise { this.#assertIsUnlocked(); - return this.#getKeyringMetadata( - await this.#persistOrRollback(async () => this.#newKeyring(type, opts)), + const keyring = await this.#persistOrRollback(async () => + this.#newKeyring(type, opts), ); + const metadata = this.#getKeyringMetadata(keyring); + const accounts = (await keyring.getAccounts()).map(normalize) as string[]; + this.messenger.publish(`${name}:keyringAdded`, { + type: keyring.type, + accounts, + metadata, + }); + return metadata; } /** @@ -1338,7 +1358,10 @@ export class KeyringController< args: any[], ): Promise { this.#assertIsUnlocked(); - return this.#persistOrRollback(async () => { + + let addedKeyringObject: KeyringObject | undefined; + + const result = await this.#persistOrRollback(async () => { let privateKey; switch (strategy) { case AccountImportStrategy.privateKey: { @@ -1390,8 +1413,19 @@ export class KeyringController< privateKey, ]); const accounts = await newKeyring.getAccounts(); + addedKeyringObject = { + type: newKeyring.type, + accounts: accounts.map(normalize) as string[], + metadata: this.#getKeyringMetadata(newKeyring), + }; return accounts[0]; }); + + if (addedKeyringObject) { + this.messenger.publish(`${name}:keyringAdded`, addedKeyringObject); + } + + return result; } /** @@ -1404,6 +1438,8 @@ export class KeyringController< async removeAccount(address: string): Promise { this.#assertIsUnlocked(); + let removedKeyringMetadata: KeyringMetadata | undefined; + await this.#persistOrRollback(async () => { const keyringIndex = await this.#findKeyringIndexForAccount(address); @@ -1442,12 +1478,16 @@ export class KeyringController< await keyring.removeAccount(address as Hex); if (shouldRemoveKeyring) { + removedKeyringMetadata = this.#getKeyringMetadata(keyring); this.#keyrings.splice(keyringIndex, 1); await this.#destroyKeyring(keyring, keyringV2); } }); this.messenger.publish(`${name}:accountRemoved`, address); + if (removedKeyringMetadata) { + this.messenger.publish(`${name}:keyringRemoved`, removedKeyringMetadata); + } } /** @@ -2206,7 +2246,10 @@ export class KeyringController< ): Promise { this.#assertIsUnlocked(); - return this.#persistOrRollback(async () => { + const addedKeyringObjects: KeyringObject[] = []; + const removedKeyringMetadatas: KeyringMetadata[] = []; + + const result = await this.#persistOrRollback(async () => { // Track created and removed keyrings during the operation execution. const createdEntries = new Set(); const removedEntries = new Set(); @@ -2284,6 +2327,14 @@ export class KeyringController< // they will be persisted in the vault. this.#keyrings = restrictedEntries; + // Collect entries to publish events for after persist succeeds. + for (const entry of createdEntries) { + addedKeyringObjects.push(await displayForKeyring(entry)); + } + for (const { metadata } of removedEntries) { + removedKeyringMetadatas.push(metadata); + } + // As usual, we want to prevent returning direct references to keyring instances, so we check // the result for any unsafe direct access before returning. for (const { keyring, keyringV2 } of [ @@ -2300,6 +2351,15 @@ export class KeyringController< return result; }); + + for (const keyringObject of addedKeyringObjects) { + this.messenger.publish(`${name}:keyringAdded`, keyringObject); + } + for (const metadata of removedKeyringMetadatas) { + this.messenger.publish(`${name}:keyringRemoved`, metadata); + } + + return result; } /** From 5e546d0997b380db19032f5881ff7e2bd9f2b966 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 20 Jul 2026 13:22:44 +0200 Subject: [PATCH 10/19] feat: add init files for entropy-controller --- packages/wallet/package.json | 1 + .../entropy-controller/entropy-controller.ts | 47 +++++++++++++++++++ .../src/initialization/instances/index.ts | 1 + packages/wallet/tsconfig.build.json | 1 + packages/wallet/tsconfig.json | 3 ++ yarn.lock | 3 +- 6 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 packages/wallet/src/initialization/instances/entropy-controller/entropy-controller.ts diff --git a/packages/wallet/package.json b/packages/wallet/package.json index a31271b98a0..3bbf92a6ade 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -62,6 +62,7 @@ "@metamask/browser-passworder": "^6.0.0", "@metamask/connectivity-controller": "^0.3.0", "@metamask/controller-utils": "^12.3.0", + "@metamask/entropy-controller": "^0.0.0", "@metamask/keyring-controller": "^27.1.0", "@metamask/messenger": "^2.0.0", "@metamask/network-controller": "^34.0.0", diff --git a/packages/wallet/src/initialization/instances/entropy-controller/entropy-controller.ts b/packages/wallet/src/initialization/instances/entropy-controller/entropy-controller.ts new file mode 100644 index 00000000000..d78d8244a2a --- /dev/null +++ b/packages/wallet/src/initialization/instances/entropy-controller/entropy-controller.ts @@ -0,0 +1,47 @@ +import { + EntropyController, + EntropyControllerMessenger, +} from '@metamask/entropy-controller'; +import { Messenger } from '@metamask/messenger'; + +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../../defaults'; +import type { InitializationConfiguration } from '../../types'; + +export const entropyController: InitializationConfiguration< + EntropyController, + EntropyControllerMessenger +> = { + name: 'EntropyController', + init: ({ state, messenger }) => + new EntropyController({ + state, + messenger, + }), + getMessenger: (parent: RootMessenger) => { + const entropyControllerMessenger: EntropyControllerMessenger = new Messenger( + { + namespace: 'EntropyController', + parent, + }, + ); + + parent.delegate({ + messenger: entropyControllerMessenger, + actions: [ + 'KeyringController:getState', + 'KeyringController:withKeyringV2Unsafe', + ], + events: [ + 'KeyringController:unlock', + 'KeyringController:keyringAdded', + 'KeyringController:keyringRemoved', + ], + }); + + return entropyControllerMessenger; + }, +}; diff --git a/packages/wallet/src/initialization/instances/index.ts b/packages/wallet/src/initialization/instances/index.ts index a15db956032..88d50e80ce2 100644 --- a/packages/wallet/src/initialization/instances/index.ts +++ b/packages/wallet/src/initialization/instances/index.ts @@ -1,4 +1,5 @@ export { accountsController } from './accounts-controller/accounts-controller'; +export { entropyController } from './entropy-controller/entropy-controller'; export { addressBookController } from './address-book-controller/address-book-controller'; export { approvalController } from './approval-controller/approval-controller'; export { connectivityController } from './connectivity-controller/connectivity-controller'; diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json index cc15d40bc18..3af4c28d267 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -11,6 +11,7 @@ { "path": "../approval-controller/tsconfig.build.json" }, { "path": "../base-controller/tsconfig.build.json" }, { "path": "../connectivity-controller/tsconfig.build.json" }, + { "path": "../entropy-controller/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json index ce310304ecc..ef7f6739aa1 100644 --- a/packages/wallet/tsconfig.json +++ b/packages/wallet/tsconfig.json @@ -19,6 +19,9 @@ { "path": "../connectivity-controller" }, + { + "path": "../entropy-controller" + }, { "path": "../controller-utils" }, diff --git a/yarn.lock b/yarn.lock index 864064886f4..2318ab580e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6593,7 +6593,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/entropy-controller@workspace:packages/entropy-controller": +"@metamask/entropy-controller@npm:^0.0.0, @metamask/entropy-controller@workspace:packages/entropy-controller": version: 0.0.0-use.local resolution: "@metamask/entropy-controller@workspace:packages/entropy-controller" dependencies: @@ -9185,6 +9185,7 @@ __metadata: "@metamask/browser-passworder": "npm:^6.0.0" "@metamask/connectivity-controller": "npm:^0.3.0" "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/entropy-controller": "npm:^0.0.0" "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^34.0.0" From 902335c65458db792c6c7db2e206fa96cca065a9 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 21 Jul 2026 18:05:10 +0200 Subject: [PATCH 11/19] refactor: simplify and use selector over state.keyrings instead --- .../src/entropy-controller.test.ts | 357 +++++++++--------- .../src/entropy-controller.ts | 72 +--- .../src/KeyringController.ts | 66 +--- .../entropy-controller/entropy-controller.ts | 6 +- 4 files changed, 198 insertions(+), 303 deletions(-) diff --git a/packages/entropy-controller/src/entropy-controller.test.ts b/packages/entropy-controller/src/entropy-controller.test.ts index 99e99c24db0..455d189fdb2 100644 --- a/packages/entropy-controller/src/entropy-controller.test.ts +++ b/packages/entropy-controller/src/entropy-controller.test.ts @@ -43,11 +43,18 @@ type Mocks = { }; }; +/** Drains all pending microtasks and macrotasks. */ +function flushPromises(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + async function setup({ keyrings = [], + isUnlocked = true, options = {}, }: { keyrings?: KeyringStub[]; + isUnlocked?: boolean; options?: Partial[0]>; } = {}): Promise<{ controller: EntropyController; @@ -69,7 +76,7 @@ async function setup({ accounts: k.accounts.map((a) => a.address), metadata: k.metadata, })), - isUnlocked: true, + isUnlocked, }) as never, ); @@ -134,11 +141,7 @@ async function setup({ 'KeyringController:getState', 'KeyringController:withKeyringV2Unsafe', ], - events: [ - 'KeyringController:unlock', - 'KeyringController:keyringAdded', - 'KeyringController:keyringRemoved', - ], + events: ['KeyringController:unlock', 'KeyringController:stateChange'], }); const controller = new EntropyController({ @@ -149,6 +152,17 @@ async function setup({ return { controller, rootMessenger, messenger, mocks }; } +/** Publishes a stateChange event with the given keyrings to trigger an entropy sync. */ +function publishKeyringStateChange( + rootMessenger: RootMessenger, + keyrings: { type: string; accounts: string[]; metadata: { id: string; name: string } }[], +): void { + rootMessenger.publish( + 'KeyringController:stateChange', + { keyrings } as never, + [], + ); +} describe('EntropyController', () => { describe('constructor', () => { @@ -178,23 +192,32 @@ describe('EntropyController', () => { }); }); - describe('syncEntropies', () => { + describe('entropy sync', () => { it('populates entropySources from an HD keyring', async () => { const keyringId = 'hd-keyring-id'; const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); + const keyringStubs = [ + { + type: 'HD Key Tree', + metadata: { id: keyringId, name: '' }, + mnemonic: HD_MNEMONIC, + accounts: [{ address: '0xabc' }], + }, + ]; - const { controller } = await setup({ - keyrings: [ - { - type: 'HD Key Tree', - metadata: { id: keyringId, name: '' }, - mnemonic: HD_MNEMONIC, - accounts: [{ address: '0xabc' }], - }, - ], + const { controller, rootMessenger } = await setup({ + keyrings: keyringStubs, }); - await controller.syncEntropies(); + publishKeyringStateChange( + rootMessenger, + keyringStubs.map((k) => ({ + type: k.type, + accounts: k.accounts.map((a) => a.address), + metadata: k.metadata, + })), + ); + await flushPromises(); expect(controller.state.entropySources).toStrictEqual({ [expectedId]: { @@ -208,18 +231,27 @@ describe('EntropyController', () => { const keyringId = 'simple-keyring-id'; const accountId = 'account-uuid-1'; const expectedId = await toEntropyId(PRIVATE_KEY_BYTES, 'raw:private-key'); + const keyringStubs = [ + { + type: 'Simple Key Pair', + metadata: { id: keyringId, name: '' }, + accounts: [{ address: '0xdef', id: accountId, privateKey: PRIVATE_KEY_HEX }], + }, + ]; - const { controller } = await setup({ - keyrings: [ - { - type: 'Simple Key Pair', - metadata: { id: keyringId, name: '' }, - accounts: [{ address: '0xdef', id: accountId, privateKey: PRIVATE_KEY_HEX }], - }, - ], + const { controller, rootMessenger } = await setup({ + keyrings: keyringStubs, }); - await controller.syncEntropies(); + publishKeyringStateChange( + rootMessenger, + keyringStubs.map((k) => ({ + type: k.type, + accounts: k.accounts.map((a) => a.address), + metadata: k.metadata, + })), + ); + await flushPromises(); expect(controller.state.entropySources).toStrictEqual({ [expectedId]: { @@ -238,21 +270,30 @@ describe('EntropyController', () => { ); const expectedId1 = await toEntropyId(PRIVATE_KEY_BYTES, 'raw:private-key'); const expectedId2 = await toEntropyId(secondKeyBytes, 'raw:private-key'); + const keyringStubs = [ + { + type: 'Simple Key Pair', + metadata: { id: keyringId, name: '' }, + accounts: [ + { address: '0xaaa', id: 'account-1', privateKey: PRIVATE_KEY_HEX }, + { address: '0xbbb', id: 'account-2', privateKey: secondKeyHex }, + ], + }, + ]; - const { controller } = await setup({ - keyrings: [ - { - type: 'Simple Key Pair', - metadata: { id: keyringId, name: '' }, - accounts: [ - { address: '0xaaa', id: 'account-1', privateKey: PRIVATE_KEY_HEX }, - { address: '0xbbb', id: 'account-2', privateKey: secondKeyHex }, - ], - }, - ], + const { controller, rootMessenger } = await setup({ + keyrings: keyringStubs, }); - await controller.syncEntropies(); + publishKeyringStateChange( + rootMessenger, + keyringStubs.map((k) => ({ + type: k.type, + accounts: k.accounts.map((a) => a.address), + metadata: k.metadata, + })), + ); + await flushPromises(); expect(controller.state.entropySources).toStrictEqual({ [expectedId1]: { @@ -267,45 +308,67 @@ describe('EntropyController', () => { }); it('ignores keyrings that do not own entropy', async () => { - const { controller } = await setup({ - keyrings: [ - { - type: 'Snap Keyring', - metadata: { id: 'snap-id', name: '' }, - accounts: [{ address: '0xabc' }], - }, - ], + const keyringStubs = [ + { + type: 'Snap Keyring', + metadata: { id: 'snap-id', name: '' }, + accounts: [{ address: '0xabc' }], + }, + ]; + + const { controller, rootMessenger } = await setup({ + keyrings: keyringStubs, }); - await controller.syncEntropies(); + publishKeyringStateChange( + rootMessenger, + keyringStubs.map((k) => ({ + type: k.type, + accounts: k.accounts.map((a) => a.address), + metadata: k.metadata, + })), + ); + await flushPromises(); expect(controller.state.entropySources).toStrictEqual({}); }); it('skips an HD keyring whose mnemonic is not yet initialised', async () => { const keyringId = 'hd-keyring-id'; + const keyringStubs = [ + { + type: 'HD Key Tree', + metadata: { id: keyringId, name: '' }, + mnemonic: null, + accounts: [], + }, + ]; - const { controller } = await setup({ - keyrings: [ - { - type: 'HD Key Tree', - metadata: { id: keyringId, name: '' }, - mnemonic: null, - accounts: [], - }, - ], + const { controller, rootMessenger } = await setup({ + keyrings: keyringStubs, }); - await controller.syncEntropies(); + publishKeyringStateChange(rootMessenger, [ + { type: 'HD Key Tree', accounts: [], metadata: { id: keyringId, name: '' } }, + ]); + await flushPromises(); expect(controller.state.entropySources).toStrictEqual({}); }); - it('replaces the entire entropySources map on each call', async () => { + it('replaces the entire entropySources map on each sync', async () => { const keyringId = 'hd-keyring-id'; const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); + const keyringStubs = [ + { + type: 'HD Key Tree', + metadata: { id: keyringId, name: '' }, + mnemonic: HD_MNEMONIC, + accounts: [{ address: '0xabc' }], + }, + ]; - const { controller } = await setup({ + const { controller, rootMessenger } = await setup({ options: { state: { entropySources: { @@ -316,17 +379,17 @@ describe('EntropyController', () => { }, }, }, - keyrings: [ - { - type: 'HD Key Tree', - metadata: { id: keyringId, name: '' }, - mnemonic: HD_MNEMONIC, - accounts: [{ address: '0xabc' }], - }, - ], + keyrings: keyringStubs, }); - await controller.syncEntropies(); + publishKeyringStateChange(rootMessenger, [ + { + type: 'HD Key Tree', + accounts: ['0xabc'], + metadata: { id: keyringId, name: '' }, + }, + ]); + await flushPromises(); expect(controller.state.entropySources).toStrictEqual({ [expectedId]: { @@ -339,24 +402,28 @@ describe('EntropyController', () => { it('does nothing when the keyring is locked', async () => { const keyringId = 'hd-keyring-id'; + const keyringStubs = [ + { + type: 'HD Key Tree', + metadata: { id: keyringId, name: '' }, + mnemonic: HD_MNEMONIC, + accounts: [{ address: '0xabc' }], + }, + ]; - const { controller, mocks } = await setup({ - keyrings: [ - { - type: 'HD Key Tree', - metadata: { id: keyringId, name: '' }, - mnemonic: HD_MNEMONIC, - accounts: [{ address: '0xabc' }], - }, - ], - }); - - mocks.KeyringController.getState.mockReturnValueOnce({ - keyrings: [], + const { controller, rootMessenger } = await setup({ + keyrings: keyringStubs, isUnlocked: false, }); - await controller.syncEntropies(); + publishKeyringStateChange(rootMessenger, [ + { + type: 'HD Key Tree', + accounts: ['0xabc'], + metadata: { id: keyringId, name: '' }, + }, + ]); + await flushPromises(); expect(controller.state.entropySources).toStrictEqual({}); }); @@ -376,10 +443,8 @@ describe('EntropyController', () => { ], }); - // Spy before publishing so we can await the async sync triggered by the event. - const syncSpy = jest.spyOn(controller, 'syncEntropies'); rootMessenger.publish('KeyringController:unlock'); - await syncSpy.mock.results[0]?.value; + await flushPromises(); expect(controller.state.entropySources).toStrictEqual({ [expectedId]: { @@ -388,11 +453,9 @@ describe('EntropyController', () => { }, }); }); - }); - describe('keyringAdded', () => { - it('merges the new HD keyring into entropySources', async () => { - const keyringId = 'new-hd-keyring-id'; + it('synchronizes automatically when keyrings state changes', async () => { + const keyringId = 'hd-keyring-id'; const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); const { controller, rootMessenger } = await setup({ @@ -406,42 +469,26 @@ describe('EntropyController', () => { ], }); - const syncSpy = jest.spyOn(controller, 'syncEntropies'); - - rootMessenger.publish('KeyringController:keyringAdded', { - type: 'HD Key Tree', - accounts: ['0xabc'], - metadata: { id: keyringId, name: '' }, - }); - - // Wait for the async merge to complete - await new Promise(process.nextTick); - await new Promise(process.nextTick); - - expect(controller.state.entropySources[expectedId]).toStrictEqual({ - type: 'bip44:srp', - metadata: { legacyEntropySource: keyringId }, - }); - // syncEntropies should NOT have been called (incremental, not full rescan) - expect(syncSpy).not.toHaveBeenCalled(); - }); - - it('ignores keyrings that do not own entropy', async () => { - const { controller, rootMessenger } = await setup(); + publishKeyringStateChange(rootMessenger, [ + { + type: 'HD Key Tree', + accounts: ['0xabc'], + metadata: { id: keyringId, name: '' }, + }, + ]); + await flushPromises(); - rootMessenger.publish('KeyringController:keyringAdded', { - type: 'Snap Keyring', - accounts: [], - metadata: { id: 'snap-id', name: '' }, + expect(controller.state.entropySources).toStrictEqual({ + [expectedId]: { + type: 'bip44:srp', + metadata: { legacyEntropySource: keyringId }, + }, }); - - await new Promise(process.nextTick); - - expect(controller.state.entropySources).toStrictEqual({}); }); - it('does nothing when the vault is locked', async () => { + it('does not synchronize when unrelated state changes', async () => { const keyringId = 'hd-keyring-id'; + const { controller, rootMessenger, mocks } = await setup({ keyrings: [ { @@ -453,65 +500,25 @@ describe('EntropyController', () => { ], }); - mocks.KeyringController.getState.mockReturnValueOnce({ - keyrings: [], - isUnlocked: false, - }); + const keyrings = [ + { type: 'HD Key Tree', accounts: ['0xabc'], metadata: { id: keyringId, name: '' } }, + ]; - rootMessenger.publish('KeyringController:keyringAdded', { - type: 'HD Key Tree', - accounts: ['0xabc'], - metadata: { id: keyringId, name: '' }, - }); + // First publish establishes the selector baseline. + publishKeyringStateChange(rootMessenger, keyrings); + await flushPromises(); - await new Promise(process.nextTick); + mocks.KeyringController.withKeyringV2Unsafe.mockClear(); - expect(controller.state.entropySources).toStrictEqual({}); - }); - }); - - describe('keyringRemoved', () => { - it('removes all entropy sources belonging to the removed keyring', async () => { - const keyringId = 'hd-keyring-id'; - const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); - - const { controller, rootMessenger } = await setup({ - options: { - state: { - entropySources: { - [expectedId]: { - type: 'bip44:srp', - metadata: { legacyEntropySource: keyringId }, - }, - 'other-entropy-id': { - type: 'bip44:srp', - metadata: { legacyEntropySource: 'other-keyring-id' }, - }, - }, - }, - }, - }); - - rootMessenger.publish('KeyringController:keyringRemoved', { - id: keyringId, - name: '', - }); - - expect(controller.state.entropySources[expectedId]).toBeUndefined(); - expect( - controller.state.entropySources['other-entropy-id'], - ).toBeDefined(); - }); - - it('does nothing when no entropy sources match the removed keyring', async () => { - const { controller, rootMessenger } = await setup(); - - rootMessenger.publish('KeyringController:keyringRemoved', { - id: 'unknown-keyring-id', - name: '', - }); + // Second publish with the SAME keyrings reference — selector equality prevents re-trigger. + rootMessenger.publish( + 'KeyringController:stateChange', + { keyrings } as never, + [], + ); + await flushPromises(); - expect(controller.state.entropySources).toStrictEqual({}); + expect(mocks.KeyringController.withKeyringV2Unsafe).not.toHaveBeenCalled(); }); }); diff --git a/packages/entropy-controller/src/entropy-controller.ts b/packages/entropy-controller/src/entropy-controller.ts index e6318edd191..604de55f3cf 100644 --- a/packages/entropy-controller/src/entropy-controller.ts +++ b/packages/entropy-controller/src/entropy-controller.ts @@ -8,8 +8,7 @@ import type { HdKeyring } from '@metamask/eth-hd-keyring/v2'; import type { SimpleKeyring } from '@metamask/eth-simple-keyring/v2'; import type { KeyringControllerGetStateAction, - KeyringControllerKeyringAddedEvent, - KeyringControllerKeyringRemovedEvent, + KeyringControllerStateChangeEvent, KeyringControllerUnlockEvent, KeyringControllerWithKeyringV2UnsafeAction, } from '@metamask/keyring-controller'; @@ -116,8 +115,7 @@ export type EntropyControllerEvents = EntropyControllerStateChangeEvent; */ type AllowedEvents = | KeyringControllerUnlockEvent - | KeyringControllerKeyringAddedEvent - | KeyringControllerKeyringRemovedEvent; + | KeyringControllerStateChangeEvent; /** * The messenger restricted to actions and events accessed by @@ -170,43 +168,19 @@ export class EntropyController extends BaseController< }); this.messenger.subscribe('KeyringController:unlock', () => { - this.syncEntropies().catch(console.error); + this.#syncEntropies().catch(console.error); }); - this.messenger.subscribe('KeyringController:keyringAdded', (keyringObject) => { - if (!isKeyringOwningEntropy(keyringObject)) { - return; - } - const { isUnlocked } = this.messenger.call('KeyringController:getState'); - if (!isUnlocked) { - return; - } - this.#syncSingleKeyring(keyringObject.metadata.id, keyringObject.type).catch( - console.error, - ); - }); - - this.messenger.subscribe('KeyringController:keyringRemoved', ({ id }) => { - this.update((state) => { - for (const [entropyId, entry] of Object.entries(state.entropySources)) { - if (entry.metadata.legacyEntropySource === id) { - delete state.entropySources[entropyId]; - } - } - }); - }); + this.messenger.subscribe( + 'KeyringController:stateChange', + () => { + this.#syncEntropies().catch(console.error); + }, + (state) => state.keyrings, + ); } - /** - * Scans the keyrings managed by `KeyringController` and rebuilds the - * `entropySources` registry from scratch. - * - * Only keyrings that own entropy are considered (see - * {@link isKeyringOwningEntropy}). This method replaces the entire - * `entropySources` map in a single state update — stale entries are - * automatically removed. - */ - async syncEntropies(): Promise { + async #syncEntropies(): Promise { const { isUnlocked, keyrings } = this.messenger.call( 'KeyringController:getState', ); @@ -233,30 +207,6 @@ export class EntropyController extends BaseController< }); } - /** - * Derives entropy source entries for a single keyring and merges them into - * `state.entropySources`. - * - * Used by the `keyringAdded` event handler to incrementally update state - * without rescanning every keyring. - * - * @param id - The keyring metadata ID. - * @param type - The keyring type string. - */ - async #syncSingleKeyring(id: string, type: string): Promise { - const newEntries: EntropyControllerState['entropySources'] = {}; - - if (type === KeyringTypes.hd) { - await this.#syncHdKeyring(id, newEntries); - } else if (type === KeyringTypes.simple) { - await this.#syncSimpleKeyring(id, newEntries); - } - - this.update((state) => { - Object.assign(state.entropySources, newEntries); - }); - } - /** * Derives the entropy source entry for a single HD keyring and adds it to * the given map. diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index e71e3b3d583..3f9351b594b 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -186,16 +186,6 @@ export type KeyringControllerUnlockEvent = { payload: []; }; -export type KeyringControllerKeyringAddedEvent = { - type: `${typeof name}:keyringAdded`; - payload: [KeyringObject]; -}; - -export type KeyringControllerKeyringRemovedEvent = { - type: `${typeof name}:keyringRemoved`; - payload: [KeyringMetadata]; -}; - export type KeyringControllerActions = | KeyringControllerGetStateAction | KeyringControllerMethodActions; @@ -204,9 +194,7 @@ export type KeyringControllerEvents = | KeyringControllerStateChangeEvent | KeyringControllerLockEvent | KeyringControllerUnlockEvent - | KeyringControllerAccountRemovedEvent - | KeyringControllerKeyringAddedEvent - | KeyringControllerKeyringRemovedEvent; + | KeyringControllerAccountRemovedEvent; export type KeyringControllerMessenger = Messenger< typeof name, @@ -1041,14 +1029,7 @@ export class KeyringController< const keyring = await this.#persistOrRollback(async () => this.#newKeyring(type, opts), ); - const metadata = this.#getKeyringMetadata(keyring); - const accounts = (await keyring.getAccounts()).map(normalize) as string[]; - this.messenger.publish(`${name}:keyringAdded`, { - type: keyring.type, - accounts, - metadata, - }); - return metadata; + return this.#getKeyringMetadata(keyring); } /** @@ -1359,9 +1340,7 @@ export class KeyringController< ): Promise { this.#assertIsUnlocked(); - let addedKeyringObject: KeyringObject | undefined; - - const result = await this.#persistOrRollback(async () => { + return this.#persistOrRollback(async () => { let privateKey; switch (strategy) { case AccountImportStrategy.privateKey: { @@ -1413,19 +1392,8 @@ export class KeyringController< privateKey, ]); const accounts = await newKeyring.getAccounts(); - addedKeyringObject = { - type: newKeyring.type, - accounts: accounts.map(normalize) as string[], - metadata: this.#getKeyringMetadata(newKeyring), - }; return accounts[0]; }); - - if (addedKeyringObject) { - this.messenger.publish(`${name}:keyringAdded`, addedKeyringObject); - } - - return result; } /** @@ -1438,8 +1406,6 @@ export class KeyringController< async removeAccount(address: string): Promise { this.#assertIsUnlocked(); - let removedKeyringMetadata: KeyringMetadata | undefined; - await this.#persistOrRollback(async () => { const keyringIndex = await this.#findKeyringIndexForAccount(address); @@ -1478,16 +1444,12 @@ export class KeyringController< await keyring.removeAccount(address as Hex); if (shouldRemoveKeyring) { - removedKeyringMetadata = this.#getKeyringMetadata(keyring); this.#keyrings.splice(keyringIndex, 1); await this.#destroyKeyring(keyring, keyringV2); } }); this.messenger.publish(`${name}:accountRemoved`, address); - if (removedKeyringMetadata) { - this.messenger.publish(`${name}:keyringRemoved`, removedKeyringMetadata); - } } /** @@ -2246,10 +2208,7 @@ export class KeyringController< ): Promise { this.#assertIsUnlocked(); - const addedKeyringObjects: KeyringObject[] = []; - const removedKeyringMetadatas: KeyringMetadata[] = []; - - const result = await this.#persistOrRollback(async () => { + return this.#persistOrRollback(async () => { // Track created and removed keyrings during the operation execution. const createdEntries = new Set(); const removedEntries = new Set(); @@ -2327,14 +2286,6 @@ export class KeyringController< // they will be persisted in the vault. this.#keyrings = restrictedEntries; - // Collect entries to publish events for after persist succeeds. - for (const entry of createdEntries) { - addedKeyringObjects.push(await displayForKeyring(entry)); - } - for (const { metadata } of removedEntries) { - removedKeyringMetadatas.push(metadata); - } - // As usual, we want to prevent returning direct references to keyring instances, so we check // the result for any unsafe direct access before returning. for (const { keyring, keyringV2 } of [ @@ -2351,15 +2302,6 @@ export class KeyringController< return result; }); - - for (const keyringObject of addedKeyringObjects) { - this.messenger.publish(`${name}:keyringAdded`, keyringObject); - } - for (const metadata of removedKeyringMetadatas) { - this.messenger.publish(`${name}:keyringRemoved`, metadata); - } - - return result; } /** diff --git a/packages/wallet/src/initialization/instances/entropy-controller/entropy-controller.ts b/packages/wallet/src/initialization/instances/entropy-controller/entropy-controller.ts index d78d8244a2a..44db9575c69 100644 --- a/packages/wallet/src/initialization/instances/entropy-controller/entropy-controller.ts +++ b/packages/wallet/src/initialization/instances/entropy-controller/entropy-controller.ts @@ -35,11 +35,7 @@ export const entropyController: InitializationConfiguration< 'KeyringController:getState', 'KeyringController:withKeyringV2Unsafe', ], - events: [ - 'KeyringController:unlock', - 'KeyringController:keyringAdded', - 'KeyringController:keyringRemoved', - ], + events: ['KeyringController:unlock', 'KeyringController:stateChange'], }); return entropyControllerMessenger; From 6a5440c4b83e09f307c291509ae6d2cb8b5a7bbd Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 21 Jul 2026 18:13:06 +0200 Subject: [PATCH 12/19] refactor: make toEntropyId sync --- .../src/entropy-controller.test.ts | 14 ++--- .../src/entropy-controller.ts | 6 +- packages/entropy-controller/src/utils.test.ts | 55 +++++++++---------- packages/entropy-controller/src/utils.ts | 23 ++++---- 4 files changed, 48 insertions(+), 50 deletions(-) diff --git a/packages/entropy-controller/src/entropy-controller.test.ts b/packages/entropy-controller/src/entropy-controller.test.ts index 455d189fdb2..822c88b2b47 100644 --- a/packages/entropy-controller/src/entropy-controller.test.ts +++ b/packages/entropy-controller/src/entropy-controller.test.ts @@ -195,7 +195,7 @@ describe('EntropyController', () => { describe('entropy sync', () => { it('populates entropySources from an HD keyring', async () => { const keyringId = 'hd-keyring-id'; - const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); + const expectedId = toEntropyId(HD_MNEMONIC, 'bip44:srp'); const keyringStubs = [ { type: 'HD Key Tree', @@ -230,7 +230,7 @@ describe('EntropyController', () => { it('populates entropySources from a Simple keyring', async () => { const keyringId = 'simple-keyring-id'; const accountId = 'account-uuid-1'; - const expectedId = await toEntropyId(PRIVATE_KEY_BYTES, 'raw:private-key'); + const expectedId = toEntropyId(PRIVATE_KEY_BYTES, 'raw:private-key'); const keyringStubs = [ { type: 'Simple Key Pair', @@ -268,8 +268,8 @@ describe('EntropyController', () => { const secondKeyBytes = Uint8Array.from( secondKeyHex.match(/../gu)!.map((b) => parseInt(b, 16)), ); - const expectedId1 = await toEntropyId(PRIVATE_KEY_BYTES, 'raw:private-key'); - const expectedId2 = await toEntropyId(secondKeyBytes, 'raw:private-key'); + const expectedId1 = toEntropyId(PRIVATE_KEY_BYTES, 'raw:private-key'); + const expectedId2 = toEntropyId(secondKeyBytes, 'raw:private-key'); const keyringStubs = [ { type: 'Simple Key Pair', @@ -358,7 +358,7 @@ describe('EntropyController', () => { it('replaces the entire entropySources map on each sync', async () => { const keyringId = 'hd-keyring-id'; - const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); + const expectedId = toEntropyId(HD_MNEMONIC, 'bip44:srp'); const keyringStubs = [ { type: 'HD Key Tree', @@ -430,7 +430,7 @@ describe('EntropyController', () => { it('synchronizes automatically when KeyringController emits unlock', async () => { const keyringId = 'hd-keyring-id'; - const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); + const expectedId = toEntropyId(HD_MNEMONIC, 'bip44:srp'); const { controller, rootMessenger } = await setup({ keyrings: [ @@ -456,7 +456,7 @@ describe('EntropyController', () => { it('synchronizes automatically when keyrings state changes', async () => { const keyringId = 'hd-keyring-id'; - const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); + const expectedId = toEntropyId(HD_MNEMONIC, 'bip44:srp'); const { controller, rootMessenger } = await setup({ keyrings: [ diff --git a/packages/entropy-controller/src/entropy-controller.ts b/packages/entropy-controller/src/entropy-controller.ts index 604de55f3cf..f23f2be4215 100644 --- a/packages/entropy-controller/src/entropy-controller.ts +++ b/packages/entropy-controller/src/entropy-controller.ts @@ -225,12 +225,12 @@ export class EntropyController extends BaseController< await this.messenger.call( 'KeyringController:withKeyringV2Unsafe', { id }, - async ({ keyring }) => { + ({ keyring }) => { const hdKeyring = keyring as HdKeyring; if (!hdKeyring.mnemonic) { return; } - const entropyId = await toEntropyId(hdKeyring.mnemonic, 'bip44:srp'); + const entropyId = toEntropyId(hdKeyring.mnemonic, 'bip44:srp'); sources[entropyId] = { type: 'bip44:srp', metadata: { legacyEntropySource: id }, @@ -265,7 +265,7 @@ export class EntropyController extends BaseController< type: 'private-key', encoding: 'hexadecimal', }); - const entropyId = await toEntropyId( + const entropyId = toEntropyId( hexToBytes(exported.privateKey), 'raw:private-key', ); diff --git a/packages/entropy-controller/src/utils.test.ts b/packages/entropy-controller/src/utils.test.ts index 6435fb6659c..4d1f84ac52b 100644 --- a/packages/entropy-controller/src/utils.test.ts +++ b/packages/entropy-controller/src/utils.test.ts @@ -22,37 +22,34 @@ describe('isKeyringOwningEntropy', () => { }); describe('toEntropyFingerprint', () => { - it('returns a 64-character lowercase hex string', async () => { - const fingerprint = await toEntropyFingerprint(SECRET, 'bip44:srp'); + it('returns a 64-character lowercase hex string', () => { + const fingerprint = toEntropyFingerprint(SECRET, 'bip44:srp'); expect(fingerprint).toHaveLength(64); expect(fingerprint).toMatch(/^[0-9a-f]+$/u); }); - it('is deterministic — same inputs always produce the same fingerprint', async () => { - const first = await toEntropyFingerprint(SECRET, 'bip44:srp'); - const second = await toEntropyFingerprint(SECRET, 'bip44:srp'); + it('is deterministic — same inputs always produce the same fingerprint', () => { + const first = toEntropyFingerprint(SECRET, 'bip44:srp'); + const second = toEntropyFingerprint(SECRET, 'bip44:srp'); expect(first).toBe(second); }); - it('is type-scoped — different entropy types produce different fingerprints for the same secret', async () => { - const srpFingerprint = await toEntropyFingerprint(SECRET, 'bip44:srp'); - const pkFingerprint = await toEntropyFingerprint( - SECRET, - 'raw:private-key', - ); + it('is type-scoped — different entropy types produce different fingerprints for the same secret', () => { + const srpFingerprint = toEntropyFingerprint(SECRET, 'bip44:srp'); + const pkFingerprint = toEntropyFingerprint(SECRET, 'raw:private-key'); expect(srpFingerprint).not.toBe(pkFingerprint); }); - it('produces different fingerprints for different secrets of the same type', async () => { + it('produces different fingerprints for different secrets of the same type', () => { const otherSecret = new Uint8Array(32).fill(2); - const first = await toEntropyFingerprint(SECRET, 'bip44:srp'); - const second = await toEntropyFingerprint(otherSecret, 'bip44:srp'); + const first = toEntropyFingerprint(SECRET, 'bip44:srp'); + const second = toEntropyFingerprint(otherSecret, 'bip44:srp'); expect(first).not.toBe(second); }); - it('matches a known value to guard against accidental algorithm changes', async () => { + it('matches a known value to guard against accidental algorithm changes', () => { // hex( HMAC-SHA256( key=Uint8Array(32).fill(1), msg='metamask:bip44:srp:fingerprint' ) ) - const fingerprint = await toEntropyFingerprint(SECRET, 'bip44:srp'); + const fingerprint = toEntropyFingerprint(SECRET, 'bip44:srp'); expect(fingerprint).toBe( '10d37bf4886303a200fe2e8147ded8e89458e37576a799ddaaacb33c89c07599', ); @@ -60,35 +57,35 @@ describe('toEntropyFingerprint', () => { }); describe('toEntropyId', () => { - it('returns a valid UUID v4 string', async () => { - const id = await toEntropyId(SECRET, 'bip44:srp'); + it('returns a valid UUID v4 string', () => { + const id = toEntropyId(SECRET, 'bip44:srp'); expect(id).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, ); }); - it('is deterministic — same inputs always produce the same ID', async () => { - const first = await toEntropyId(SECRET, 'bip44:srp'); - const second = await toEntropyId(SECRET, 'bip44:srp'); + it('is deterministic — same inputs always produce the same ID', () => { + const first = toEntropyId(SECRET, 'bip44:srp'); + const second = toEntropyId(SECRET, 'bip44:srp'); expect(first).toBe(second); }); - it('is type-scoped — different entropy types produce different IDs for the same secret', async () => { - const srpId = await toEntropyId(SECRET, 'bip44:srp'); - const pkId = await toEntropyId(SECRET, 'raw:private-key'); + it('is type-scoped — different entropy types produce different IDs for the same secret', () => { + const srpId = toEntropyId(SECRET, 'bip44:srp'); + const pkId = toEntropyId(SECRET, 'raw:private-key'); expect(srpId).not.toBe(pkId); }); - it('produces different IDs for different secrets of the same type', async () => { + it('produces different IDs for different secrets of the same type', () => { const otherSecret = new Uint8Array(32).fill(2); - const first = await toEntropyId(SECRET, 'bip44:srp'); - const second = await toEntropyId(otherSecret, 'bip44:srp'); + const first = toEntropyId(SECRET, 'bip44:srp'); + const second = toEntropyId(otherSecret, 'bip44:srp'); expect(first).not.toBe(second); }); - it('matches a known value to guard against accidental algorithm changes', async () => { + it('matches a known value to guard against accidental algorithm changes', () => { // uuid({ random: HMAC-SHA256( key=Uint8Array(32).fill(1), msg='metamask:bip44:srp:fingerprint' ).slice(0, 16) }) - const id = await toEntropyId(SECRET, 'bip44:srp'); + const id = toEntropyId(SECRET, 'bip44:srp'); expect(id).toBe('10d37bf4-8863-43a2-80fe-2e8147ded8e8'); }); }); diff --git a/packages/entropy-controller/src/utils.ts b/packages/entropy-controller/src/utils.ts index becbf4452a2..8e796c77709 100644 --- a/packages/entropy-controller/src/utils.ts +++ b/packages/entropy-controller/src/utils.ts @@ -29,14 +29,18 @@ export function isKeyringOwningEntropy(keyring: KeyringObject): boolean { * Using the secret as the HMAC key and the `EntropyType` as the domain * separator ensures non-reversibility and type-scoping. * + * Synchronous because `@noble/hashes` exposes a synchronous HMAC API, which + * guarantees that entropy sources are derived in the same tick as the keyring + * state change that triggers a sync — no async boundary between the two. + * * @param secret - The raw entropy bytes. * @param entropyType - The type of entropy source. * @returns The 32-byte HMAC-SHA256 digest. */ -async function toEntropyFingerprintBytes( +function toEntropyFingerprintBytes( secret: Uint8Array, entropyType: EntropyType, -): Promise { +): Uint8Array { const message = new TextEncoder().encode( `metamask:${entropyType}:fingerprint`, ); @@ -51,20 +55,17 @@ async function toEntropyFingerprintBytes( * It is suitable for comparison and auditing — e.g. detecting that two * separate components hold the same underlying secret. * - * The return type is `Promise` to allow a future migration to the Web - * Crypto API without breaking callers. - * * @param secret - The raw entropy bytes (e.g. BIP-39 mnemonic bytes, a 32-byte * private key). * @param entropyType - The type of entropy source, expressed as * `category:implementation` (e.g. `'bip44:srp'`, `'raw:private-key'`). * @returns The lowercase hex-encoded 32-byte HMAC-SHA256 digest. */ -export async function toEntropyFingerprint( +export function toEntropyFingerprint( secret: Uint8Array, entropyType: EntropyType, -): Promise { - return bytesToHex(await toEntropyFingerprintBytes(secret, entropyType)); +): string { + return bytesToHex(toEntropyFingerprintBytes(secret, entropyType)); } /** @@ -82,10 +83,10 @@ export async function toEntropyFingerprint( * @returns A deterministic UUID v4 string suitable for use as an * {@link EntropyId}. */ -export async function toEntropyId( +export function toEntropyId( secret: Uint8Array, entropyType: EntropyType, -): Promise { - const bytes = await toEntropyFingerprintBytes(secret, entropyType); +): EntropyId { + const bytes = toEntropyFingerprintBytes(secret, entropyType); return uuid({ random: bytes.slice(0, 16) }); } From 03430cb7fe0095b1f94e5166f517983ba6a53c22 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 21 Jul 2026 18:17:11 +0200 Subject: [PATCH 13/19] refactor: remove need for :onUnlock event --- .../src/entropy-controller.test.ts | 29 +++++++++++++------ .../src/entropy-controller.ts | 12 +++----- .../entropy-controller/entropy-controller.ts | 2 +- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/packages/entropy-controller/src/entropy-controller.test.ts b/packages/entropy-controller/src/entropy-controller.test.ts index 822c88b2b47..efa76770ae1 100644 --- a/packages/entropy-controller/src/entropy-controller.test.ts +++ b/packages/entropy-controller/src/entropy-controller.test.ts @@ -50,11 +50,9 @@ function flushPromises(): Promise { async function setup({ keyrings = [], - isUnlocked = true, options = {}, }: { keyrings?: KeyringStub[]; - isUnlocked?: boolean; options?: Partial[0]>; } = {}): Promise<{ controller: EntropyController; @@ -76,7 +74,7 @@ async function setup({ accounts: k.accounts.map((a) => a.address), metadata: k.metadata, })), - isUnlocked, + isUnlocked: true, }) as never, ); @@ -141,7 +139,7 @@ async function setup({ 'KeyringController:getState', 'KeyringController:withKeyringV2Unsafe', ], - events: ['KeyringController:unlock', 'KeyringController:stateChange'], + events: ['KeyringController:stateChange'], }); const controller = new EntropyController({ @@ -400,7 +398,7 @@ describe('EntropyController', () => { expect(controller.state.entropySources['stale-id']).toBeUndefined(); }); - it('does nothing when the keyring is locked', async () => { + it('does nothing when the vault is locked', async () => { const keyringId = 'hd-keyring-id'; const keyringStubs = [ { @@ -411,8 +409,12 @@ describe('EntropyController', () => { }, ]; - const { controller, rootMessenger } = await setup({ + const { controller, rootMessenger, mocks } = await setup({ keyrings: keyringStubs, + }); + + mocks.KeyringController.getState.mockReturnValueOnce({ + keyrings: [], isUnlocked: false, }); @@ -428,7 +430,7 @@ describe('EntropyController', () => { expect(controller.state.entropySources).toStrictEqual({}); }); - it('synchronizes automatically when KeyringController emits unlock', async () => { + it('synchronizes automatically when keyrings state changes', async () => { const keyringId = 'hd-keyring-id'; const expectedId = toEntropyId(HD_MNEMONIC, 'bip44:srp'); @@ -443,7 +445,13 @@ describe('EntropyController', () => { ], }); - rootMessenger.publish('KeyringController:unlock'); + publishKeyringStateChange(rootMessenger, [ + { + type: 'HD Key Tree', + accounts: ['0xabc'], + metadata: { id: keyringId, name: '' }, + }, + ]); await flushPromises(); expect(controller.state.entropySources).toStrictEqual({ @@ -454,7 +462,9 @@ describe('EntropyController', () => { }); }); - it('synchronizes automatically when keyrings state changes', async () => { + it('synchronizes when the vault unlocks (keyrings go from empty to populated)', async () => { + // Unlocking populates state.keyrings, which triggers the stateChange + // selector and kicks off a sync — no separate unlock subscription needed. const keyringId = 'hd-keyring-id'; const expectedId = toEntropyId(HD_MNEMONIC, 'bip44:srp'); @@ -469,6 +479,7 @@ describe('EntropyController', () => { ], }); + // Simulate unlock: keyrings transition from [] to the restored set. publishKeyringStateChange(rootMessenger, [ { type: 'HD Key Tree', diff --git a/packages/entropy-controller/src/entropy-controller.ts b/packages/entropy-controller/src/entropy-controller.ts index f23f2be4215..e90b3fac863 100644 --- a/packages/entropy-controller/src/entropy-controller.ts +++ b/packages/entropy-controller/src/entropy-controller.ts @@ -9,7 +9,6 @@ import type { SimpleKeyring } from '@metamask/eth-simple-keyring/v2'; import type { KeyringControllerGetStateAction, KeyringControllerStateChangeEvent, - KeyringControllerUnlockEvent, KeyringControllerWithKeyringV2UnsafeAction, } from '@metamask/keyring-controller'; import { KeyringTypes } from '@metamask/keyring-controller'; @@ -113,9 +112,7 @@ export type EntropyControllerEvents = EntropyControllerStateChangeEvent; * Events from other messengers that {@link EntropyControllerMessenger} * subscribes to. */ -type AllowedEvents = - | KeyringControllerUnlockEvent - | KeyringControllerStateChangeEvent; +type AllowedEvents = KeyringControllerStateChangeEvent; /** * The messenger restricted to actions and events accessed by @@ -167,10 +164,9 @@ export class EntropyController extends BaseController< }, }); - this.messenger.subscribe('KeyringController:unlock', () => { - this.#syncEntropies().catch(console.error); - }); - + // Syncing on `keyrings` state changes covers all cases, including vault + // unlock: unlocking populates `state.keyrings`, which triggers this + // selector and kicks off a sync automatically. this.messenger.subscribe( 'KeyringController:stateChange', () => { diff --git a/packages/wallet/src/initialization/instances/entropy-controller/entropy-controller.ts b/packages/wallet/src/initialization/instances/entropy-controller/entropy-controller.ts index 44db9575c69..c798db82b4a 100644 --- a/packages/wallet/src/initialization/instances/entropy-controller/entropy-controller.ts +++ b/packages/wallet/src/initialization/instances/entropy-controller/entropy-controller.ts @@ -35,7 +35,7 @@ export const entropyController: InitializationConfiguration< 'KeyringController:getState', 'KeyringController:withKeyringV2Unsafe', ], - events: ['KeyringController:unlock', 'KeyringController:stateChange'], + events: ['KeyringController:stateChange'], }); return entropyControllerMessenger; From 28d287290fb05eb96fe22fae3780e8cbde0e51f7 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 21 Jul 2026 18:21:11 +0200 Subject: [PATCH 14/19] chore: cosmetic --- .../src/entropy-controller.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/entropy-controller/src/entropy-controller.ts b/packages/entropy-controller/src/entropy-controller.ts index e90b3fac863..c7ec7c76f9a 100644 --- a/packages/entropy-controller/src/entropy-controller.ts +++ b/packages/entropy-controller/src/entropy-controller.ts @@ -184,22 +184,19 @@ export class EntropyController extends BaseController< return; } - const entropyKeyrings = keyrings.filter(isKeyringOwningEntropy); + const entropySources: EntropyControllerState['entropySources'] = {}; + for (const keyring of keyrings.filter(isKeyringOwningEntropy)) { + const { id } = keyring.metadata; - const newSources: EntropyControllerState['entropySources'] = {}; - - for (const keyringObj of entropyKeyrings) { - const { id } = keyringObj.metadata; - - if (keyringObj.type === KeyringTypes.hd) { - await this.#syncHdKeyring(id, newSources); - } else if (keyringObj.type === KeyringTypes.simple) { - await this.#syncSimpleKeyring(id, newSources); + if (keyring.type === KeyringTypes.hd) { + await this.#syncHdKeyring(id, entropySources); + } else if (keyring.type === KeyringTypes.simple) { + await this.#syncSimpleKeyring(id, entropySources); } } this.update((state) => { - state.entropySources = newSources; + state.entropySources = entropySources; }); } From bee0234c4ca135a06e0de40fbf1c63608ec38155 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 21 Jul 2026 18:24:07 +0200 Subject: [PATCH 15/19] refactor: back to async again --- .../src/entropy-controller.test.ts | 14 ++--- .../src/entropy-controller.ts | 6 +-- packages/entropy-controller/src/utils.test.ts | 52 +++++++++---------- packages/entropy-controller/src/utils.ts | 22 ++++---- 4 files changed, 47 insertions(+), 47 deletions(-) diff --git a/packages/entropy-controller/src/entropy-controller.test.ts b/packages/entropy-controller/src/entropy-controller.test.ts index efa76770ae1..f6915c49b6e 100644 --- a/packages/entropy-controller/src/entropy-controller.test.ts +++ b/packages/entropy-controller/src/entropy-controller.test.ts @@ -193,7 +193,7 @@ describe('EntropyController', () => { describe('entropy sync', () => { it('populates entropySources from an HD keyring', async () => { const keyringId = 'hd-keyring-id'; - const expectedId = toEntropyId(HD_MNEMONIC, 'bip44:srp'); + const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); const keyringStubs = [ { type: 'HD Key Tree', @@ -228,7 +228,7 @@ describe('EntropyController', () => { it('populates entropySources from a Simple keyring', async () => { const keyringId = 'simple-keyring-id'; const accountId = 'account-uuid-1'; - const expectedId = toEntropyId(PRIVATE_KEY_BYTES, 'raw:private-key'); + const expectedId = await toEntropyId(PRIVATE_KEY_BYTES, 'raw:private-key'); const keyringStubs = [ { type: 'Simple Key Pair', @@ -266,8 +266,8 @@ describe('EntropyController', () => { const secondKeyBytes = Uint8Array.from( secondKeyHex.match(/../gu)!.map((b) => parseInt(b, 16)), ); - const expectedId1 = toEntropyId(PRIVATE_KEY_BYTES, 'raw:private-key'); - const expectedId2 = toEntropyId(secondKeyBytes, 'raw:private-key'); + const expectedId1 = await toEntropyId(PRIVATE_KEY_BYTES, 'raw:private-key'); + const expectedId2 = await toEntropyId(secondKeyBytes, 'raw:private-key'); const keyringStubs = [ { type: 'Simple Key Pair', @@ -356,7 +356,7 @@ describe('EntropyController', () => { it('replaces the entire entropySources map on each sync', async () => { const keyringId = 'hd-keyring-id'; - const expectedId = toEntropyId(HD_MNEMONIC, 'bip44:srp'); + const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); const keyringStubs = [ { type: 'HD Key Tree', @@ -432,7 +432,7 @@ describe('EntropyController', () => { it('synchronizes automatically when keyrings state changes', async () => { const keyringId = 'hd-keyring-id'; - const expectedId = toEntropyId(HD_MNEMONIC, 'bip44:srp'); + const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); const { controller, rootMessenger } = await setup({ keyrings: [ @@ -466,7 +466,7 @@ describe('EntropyController', () => { // Unlocking populates state.keyrings, which triggers the stateChange // selector and kicks off a sync — no separate unlock subscription needed. const keyringId = 'hd-keyring-id'; - const expectedId = toEntropyId(HD_MNEMONIC, 'bip44:srp'); + const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); const { controller, rootMessenger } = await setup({ keyrings: [ diff --git a/packages/entropy-controller/src/entropy-controller.ts b/packages/entropy-controller/src/entropy-controller.ts index c7ec7c76f9a..9215d48036e 100644 --- a/packages/entropy-controller/src/entropy-controller.ts +++ b/packages/entropy-controller/src/entropy-controller.ts @@ -218,12 +218,12 @@ export class EntropyController extends BaseController< await this.messenger.call( 'KeyringController:withKeyringV2Unsafe', { id }, - ({ keyring }) => { + async ({ keyring }) => { const hdKeyring = keyring as HdKeyring; if (!hdKeyring.mnemonic) { return; } - const entropyId = toEntropyId(hdKeyring.mnemonic, 'bip44:srp'); + const entropyId = await toEntropyId(hdKeyring.mnemonic, 'bip44:srp'); sources[entropyId] = { type: 'bip44:srp', metadata: { legacyEntropySource: id }, @@ -258,7 +258,7 @@ export class EntropyController extends BaseController< type: 'private-key', encoding: 'hexadecimal', }); - const entropyId = toEntropyId( + const entropyId = await toEntropyId( hexToBytes(exported.privateKey), 'raw:private-key', ); diff --git a/packages/entropy-controller/src/utils.test.ts b/packages/entropy-controller/src/utils.test.ts index 4d1f84ac52b..5eab9b50a07 100644 --- a/packages/entropy-controller/src/utils.test.ts +++ b/packages/entropy-controller/src/utils.test.ts @@ -22,34 +22,34 @@ describe('isKeyringOwningEntropy', () => { }); describe('toEntropyFingerprint', () => { - it('returns a 64-character lowercase hex string', () => { - const fingerprint = toEntropyFingerprint(SECRET, 'bip44:srp'); + it('returns a 64-character lowercase hex string', async () => { + const fingerprint = await toEntropyFingerprint(SECRET, 'bip44:srp'); expect(fingerprint).toHaveLength(64); expect(fingerprint).toMatch(/^[0-9a-f]+$/u); }); - it('is deterministic — same inputs always produce the same fingerprint', () => { - const first = toEntropyFingerprint(SECRET, 'bip44:srp'); - const second = toEntropyFingerprint(SECRET, 'bip44:srp'); + it('is deterministic — same inputs always produce the same fingerprint', async () => { + const first = await toEntropyFingerprint(SECRET, 'bip44:srp'); + const second = await toEntropyFingerprint(SECRET, 'bip44:srp'); expect(first).toBe(second); }); - it('is type-scoped — different entropy types produce different fingerprints for the same secret', () => { - const srpFingerprint = toEntropyFingerprint(SECRET, 'bip44:srp'); - const pkFingerprint = toEntropyFingerprint(SECRET, 'raw:private-key'); + it('is type-scoped — different entropy types produce different fingerprints for the same secret', async () => { + const srpFingerprint = await toEntropyFingerprint(SECRET, 'bip44:srp'); + const pkFingerprint = await toEntropyFingerprint(SECRET, 'raw:private-key'); expect(srpFingerprint).not.toBe(pkFingerprint); }); - it('produces different fingerprints for different secrets of the same type', () => { + it('produces different fingerprints for different secrets of the same type', async () => { const otherSecret = new Uint8Array(32).fill(2); - const first = toEntropyFingerprint(SECRET, 'bip44:srp'); - const second = toEntropyFingerprint(otherSecret, 'bip44:srp'); + const first = await toEntropyFingerprint(SECRET, 'bip44:srp'); + const second = await toEntropyFingerprint(otherSecret, 'bip44:srp'); expect(first).not.toBe(second); }); - it('matches a known value to guard against accidental algorithm changes', () => { + it('matches a known value to guard against accidental algorithm changes', async () => { // hex( HMAC-SHA256( key=Uint8Array(32).fill(1), msg='metamask:bip44:srp:fingerprint' ) ) - const fingerprint = toEntropyFingerprint(SECRET, 'bip44:srp'); + const fingerprint = await toEntropyFingerprint(SECRET, 'bip44:srp'); expect(fingerprint).toBe( '10d37bf4886303a200fe2e8147ded8e89458e37576a799ddaaacb33c89c07599', ); @@ -57,35 +57,35 @@ describe('toEntropyFingerprint', () => { }); describe('toEntropyId', () => { - it('returns a valid UUID v4 string', () => { - const id = toEntropyId(SECRET, 'bip44:srp'); + it('returns a valid UUID v4 string', async () => { + const id = await toEntropyId(SECRET, 'bip44:srp'); expect(id).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, ); }); - it('is deterministic — same inputs always produce the same ID', () => { - const first = toEntropyId(SECRET, 'bip44:srp'); - const second = toEntropyId(SECRET, 'bip44:srp'); + it('is deterministic — same inputs always produce the same ID', async () => { + const first = await toEntropyId(SECRET, 'bip44:srp'); + const second = await toEntropyId(SECRET, 'bip44:srp'); expect(first).toBe(second); }); - it('is type-scoped — different entropy types produce different IDs for the same secret', () => { - const srpId = toEntropyId(SECRET, 'bip44:srp'); - const pkId = toEntropyId(SECRET, 'raw:private-key'); + it('is type-scoped — different entropy types produce different IDs for the same secret', async () => { + const srpId = await toEntropyId(SECRET, 'bip44:srp'); + const pkId = await toEntropyId(SECRET, 'raw:private-key'); expect(srpId).not.toBe(pkId); }); - it('produces different IDs for different secrets of the same type', () => { + it('produces different IDs for different secrets of the same type', async () => { const otherSecret = new Uint8Array(32).fill(2); - const first = toEntropyId(SECRET, 'bip44:srp'); - const second = toEntropyId(otherSecret, 'bip44:srp'); + const first = await toEntropyId(SECRET, 'bip44:srp'); + const second = await toEntropyId(otherSecret, 'bip44:srp'); expect(first).not.toBe(second); }); - it('matches a known value to guard against accidental algorithm changes', () => { + it('matches a known value to guard against accidental algorithm changes', async () => { // uuid({ random: HMAC-SHA256( key=Uint8Array(32).fill(1), msg='metamask:bip44:srp:fingerprint' ).slice(0, 16) }) - const id = toEntropyId(SECRET, 'bip44:srp'); + const id = await toEntropyId(SECRET, 'bip44:srp'); expect(id).toBe('10d37bf4-8863-43a2-80fe-2e8147ded8e8'); }); }); diff --git a/packages/entropy-controller/src/utils.ts b/packages/entropy-controller/src/utils.ts index 8e796c77709..32e439ec18c 100644 --- a/packages/entropy-controller/src/utils.ts +++ b/packages/entropy-controller/src/utils.ts @@ -29,18 +29,18 @@ export function isKeyringOwningEntropy(keyring: KeyringObject): boolean { * Using the secret as the HMAC key and the `EntropyType` as the domain * separator ensures non-reversibility and type-scoping. * - * Synchronous because `@noble/hashes` exposes a synchronous HMAC API, which - * guarantees that entropy sources are derived in the same tick as the keyring - * state change that triggers a sync — no async boundary between the two. + * `@noble/hashes` is synchronous today, but the async signature is kept as a + * forward-compatible seam: a future migration to the Web Crypto API (or any + * other async primitive) won't require changes at every call site. * * @param secret - The raw entropy bytes. * @param entropyType - The type of entropy source. * @returns The 32-byte HMAC-SHA256 digest. */ -function toEntropyFingerprintBytes( +async function toEntropyFingerprintBytes( secret: Uint8Array, entropyType: EntropyType, -): Uint8Array { +): Promise { const message = new TextEncoder().encode( `metamask:${entropyType}:fingerprint`, ); @@ -61,11 +61,11 @@ function toEntropyFingerprintBytes( * `category:implementation` (e.g. `'bip44:srp'`, `'raw:private-key'`). * @returns The lowercase hex-encoded 32-byte HMAC-SHA256 digest. */ -export function toEntropyFingerprint( +export async function toEntropyFingerprint( secret: Uint8Array, entropyType: EntropyType, -): string { - return bytesToHex(toEntropyFingerprintBytes(secret, entropyType)); +): Promise { + return bytesToHex(await toEntropyFingerprintBytes(secret, entropyType)); } /** @@ -83,10 +83,10 @@ export function toEntropyFingerprint( * @returns A deterministic UUID v4 string suitable for use as an * {@link EntropyId}. */ -export function toEntropyId( +export async function toEntropyId( secret: Uint8Array, entropyType: EntropyType, -): EntropyId { - const bytes = toEntropyFingerprintBytes(secret, entropyType); +): Promise { + const bytes = await toEntropyFingerprintBytes(secret, entropyType); return uuid({ random: bytes.slice(0, 16) }); } From f8895c927bb062e8339c25ea830d1c69fd5be02a Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 22 Jul 2026 10:37:32 +0200 Subject: [PATCH 16/19] refactor: flip logic (register entropy sources from the KeyringController) --- .../src/entropy-controller.test.ts | 476 +++--------------- .../src/entropy-controller.ts | 198 ++++---- .../src/KeyringController.test.ts | 36 +- .../src/KeyringController.ts | 130 ++++- .../entropy-controller/entropy-controller.ts | 24 +- 5 files changed, 314 insertions(+), 550 deletions(-) diff --git a/packages/entropy-controller/src/entropy-controller.test.ts b/packages/entropy-controller/src/entropy-controller.test.ts index f6915c49b6e..6649d5c53b8 100644 --- a/packages/entropy-controller/src/entropy-controller.test.ts +++ b/packages/entropy-controller/src/entropy-controller.test.ts @@ -14,115 +14,29 @@ import { toEntropyId } from './utils'; const HD_MNEMONIC = new TextEncoder().encode( 'test test test test test test test test test test test junk', ); -const PRIVATE_KEY_HEX = - '4af1bceebf7f3634ec3cff8a2c38e51178d5d4ce585c52d6043e5e2cc3f1d3e1'; const PRIVATE_KEY_BYTES = Uint8Array.from( - PRIVATE_KEY_HEX.match(/../gu)!.map((b) => parseInt(b, 16)), + '4af1bceebf7f3634ec3cff8a2c38e51178d5d4ce585c52d6043e5e2cc3f1d3e1' + .match(/../gu)! + .map((b) => parseInt(b, 16)), ); -type KeyringStub = { - type: string; - metadata: { id: string; name: string }; - /** HD keyring: mnemonic bytes, or null if not yet initialised. */ - mnemonic?: Uint8Array | null; - /** Accounts held by this keyring. `id` and `privateKey` are only relevant for Simple keyrings. */ - accounts: { address: string; id?: string; privateKey?: string }[]; -}; - type RootMessenger = Messenger< MockAnyNamespace, MessengerActions, MessengerEvents >; -type Mocks = { - // eslint-disable-next-line @typescript-eslint/naming-convention - KeyringController: { - getState: jest.Mock; - withKeyringV2Unsafe: jest.Mock; - }; -}; - -/** Drains all pending microtasks and macrotasks. */ -function flushPromises(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)); -} - async function setup({ - keyrings = [], options = {}, }: { - keyrings?: KeyringStub[]; options?: Partial[0]>; } = {}): Promise<{ controller: EntropyController; rootMessenger: RootMessenger; - messenger: EntropyControllerMessenger; - mocks: Mocks; }> { - const mocks: Mocks = { - KeyringController: { - getState: jest.fn(), - withKeyringV2Unsafe: jest.fn(), - }, - }; - - mocks.KeyringController.getState.mockImplementation( - () => ({ - keyrings: keyrings.map((k) => ({ - type: k.type, - accounts: k.accounts.map((a) => a.address), - metadata: k.metadata, - })), - isUnlocked: true, - }) as never, - ); - - mocks.KeyringController.withKeyringV2Unsafe.mockImplementation( - async ( - selector: { id: string }, - operation: (payload: { - keyring: unknown; - metadata: unknown; - }) => Promise, - ) => { - const stub = keyrings.find((k) => k.metadata.id === selector.id); - return operation({ - keyring: { - mnemonic: stub?.mnemonic ?? null, - getAccounts: async () => - (stub?.accounts ?? []) - .filter((a) => a.id !== undefined) - .map(({ id }) => ({ id })), - exportAccount: async (accountId: string) => { - const account = stub?.accounts.find((a) => a.id === accountId); - return { - type: 'private-key', - encoding: 'hexadecimal', - privateKey: account?.privateKey ?? '', - }; - }, - }, - metadata: { id: selector.id, name: '' }, - }); - }, - ); - - const rootMessenger = new Messenger< - MockAnyNamespace, - MessengerActions, - MessengerEvents - >({ namespace: MOCK_ANY_NAMESPACE }); - - rootMessenger.registerActionHandler( - 'KeyringController:getState', - mocks.KeyringController.getState, - ); - - rootMessenger.registerActionHandler( - 'KeyringController:withKeyringV2Unsafe', - mocks.KeyringController.withKeyringV2Unsafe, - ); + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); const messenger = new Messenger< 'EntropyController', @@ -133,33 +47,9 @@ async function setup({ parent: rootMessenger, }); - rootMessenger.delegate({ - messenger, - actions: [ - 'KeyringController:getState', - 'KeyringController:withKeyringV2Unsafe', - ], - events: ['KeyringController:stateChange'], - }); - - const controller = new EntropyController({ - messenger, - ...options, - }); - - return { controller, rootMessenger, messenger, mocks }; -} + const controller = new EntropyController({ messenger, ...options }); -/** Publishes a stateChange event with the given keyrings to trigger an entropy sync. */ -function publishKeyringStateChange( - rootMessenger: RootMessenger, - keyrings: { type: string; accounts: string[]; metadata: { id: string; name: string } }[], -): void { - rootMessenger.publish( - 'KeyringController:stateChange', - { keyrings } as never, - [], - ); + return { controller, rootMessenger }; } describe('EntropyController', () => { @@ -169,7 +59,7 @@ describe('EntropyController', () => { entropySources: { 'entropy-1': { type: 'bip44:srp' as const, - metadata: {}, + metadata: { legacyEntropySource: 'keyring-1' }, }, }, }; @@ -190,32 +80,18 @@ describe('EntropyController', () => { }); }); - describe('entropy sync', () => { - it('populates entropySources from an HD keyring', async () => { + describe('registerSource', () => { + it('registers an HD keyring entropy source', async () => { const keyringId = 'hd-keyring-id'; const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); - const keyringStubs = [ - { - type: 'HD Key Tree', - metadata: { id: keyringId, name: '' }, - mnemonic: HD_MNEMONIC, - accounts: [{ address: '0xabc' }], - }, - ]; - const { controller, rootMessenger } = await setup({ - keyrings: keyringStubs, - }); + const { controller, rootMessenger } = await setup(); - publishKeyringStateChange( - rootMessenger, - keyringStubs.map((k) => ({ - type: k.type, - accounts: k.accounts.map((a) => a.address), - metadata: k.metadata, - })), - ); - await flushPromises(); + await rootMessenger.call('EntropyController:registerSource', { + type: 'bip44:srp', + mnemonic: HD_MNEMONIC, + metadata: { legacyEntropySource: keyringId }, + }); expect(controller.state.entropySources).toStrictEqual({ [expectedId]: { @@ -225,31 +101,17 @@ describe('EntropyController', () => { }); }); - it('populates entropySources from a Simple keyring', async () => { + it('registers a Simple keyring entropy source', async () => { const keyringId = 'simple-keyring-id'; - const accountId = 'account-uuid-1'; const expectedId = await toEntropyId(PRIVATE_KEY_BYTES, 'raw:private-key'); - const keyringStubs = [ - { - type: 'Simple Key Pair', - metadata: { id: keyringId, name: '' }, - accounts: [{ address: '0xdef', id: accountId, privateKey: PRIVATE_KEY_HEX }], - }, - ]; - const { controller, rootMessenger } = await setup({ - keyrings: keyringStubs, - }); + const { controller, rootMessenger } = await setup(); - publishKeyringStateChange( - rootMessenger, - keyringStubs.map((k) => ({ - type: k.type, - accounts: k.accounts.map((a) => a.address), - metadata: k.metadata, - })), - ); - await flushPromises(); + await rootMessenger.call('EntropyController:registerSource', { + type: 'raw:private-key', + privateKey: PRIVATE_KEY_BYTES, + metadata: { legacyEntropySource: keyringId }, + }); expect(controller.state.entropySources).toStrictEqual({ [expectedId]: { @@ -259,277 +121,103 @@ describe('EntropyController', () => { }); }); - it('handles a Simple keyring with multiple accounts', async () => { - const keyringId = 'simple-keyring-id'; - const secondKeyHex = - '1111111111111111111111111111111111111111111111111111111111111111'; - const secondKeyBytes = Uint8Array.from( - secondKeyHex.match(/../gu)!.map((b) => parseInt(b, 16)), + it('merges new sources without replacing existing ones', async () => { + const hdKeyringId = 'hd-keyring-id'; + const simpleKeyringId = 'simple-keyring-id'; + const expectedHdId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); + const expectedSimpleId = await toEntropyId( + PRIVATE_KEY_BYTES, + 'raw:private-key', ); - const expectedId1 = await toEntropyId(PRIVATE_KEY_BYTES, 'raw:private-key'); - const expectedId2 = await toEntropyId(secondKeyBytes, 'raw:private-key'); - const keyringStubs = [ - { - type: 'Simple Key Pair', - metadata: { id: keyringId, name: '' }, - accounts: [ - { address: '0xaaa', id: 'account-1', privateKey: PRIVATE_KEY_HEX }, - { address: '0xbbb', id: 'account-2', privateKey: secondKeyHex }, - ], - }, - ]; - const { controller, rootMessenger } = await setup({ - keyrings: keyringStubs, + const { controller, rootMessenger } = await setup(); + + await rootMessenger.call('EntropyController:registerSource', { + type: 'bip44:srp', + mnemonic: HD_MNEMONIC, + metadata: { legacyEntropySource: hdKeyringId }, }); - publishKeyringStateChange( - rootMessenger, - keyringStubs.map((k) => ({ - type: k.type, - accounts: k.accounts.map((a) => a.address), - metadata: k.metadata, - })), - ); - await flushPromises(); + await rootMessenger.call('EntropyController:registerSource', { + type: 'raw:private-key', + privateKey: PRIVATE_KEY_BYTES, + metadata: { legacyEntropySource: simpleKeyringId }, + }); expect(controller.state.entropySources).toStrictEqual({ - [expectedId1]: { - type: 'raw:private-key', - metadata: { legacyEntropySource: keyringId }, + [expectedHdId]: { + type: 'bip44:srp', + metadata: { legacyEntropySource: hdKeyringId }, }, - [expectedId2]: { + [expectedSimpleId]: { type: 'raw:private-key', - metadata: { legacyEntropySource: keyringId }, - }, - }); - }); - - it('ignores keyrings that do not own entropy', async () => { - const keyringStubs = [ - { - type: 'Snap Keyring', - metadata: { id: 'snap-id', name: '' }, - accounts: [{ address: '0xabc' }], + metadata: { legacyEntropySource: simpleKeyringId }, }, - ]; - - const { controller, rootMessenger } = await setup({ - keyrings: keyringStubs, }); - - publishKeyringStateChange( - rootMessenger, - keyringStubs.map((k) => ({ - type: k.type, - accounts: k.accounts.map((a) => a.address), - metadata: k.metadata, - })), - ); - await flushPromises(); - - expect(controller.state.entropySources).toStrictEqual({}); }); - it('skips an HD keyring whose mnemonic is not yet initialised', async () => { + it('is idempotent — registering the same source twice yields one entry', async () => { const keyringId = 'hd-keyring-id'; - const keyringStubs = [ - { - type: 'HD Key Tree', - metadata: { id: keyringId, name: '' }, - mnemonic: null, - accounts: [], - }, - ]; + const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); - const { controller, rootMessenger } = await setup({ - keyrings: keyringStubs, + const { controller, rootMessenger } = await setup(); + + await rootMessenger.call('EntropyController:registerSource', { + type: 'bip44:srp', + mnemonic: HD_MNEMONIC, + metadata: { legacyEntropySource: keyringId }, }); - publishKeyringStateChange(rootMessenger, [ - { type: 'HD Key Tree', accounts: [], metadata: { id: keyringId, name: '' } }, - ]); - await flushPromises(); + await rootMessenger.call('EntropyController:registerSource', { + type: 'bip44:srp', + mnemonic: HD_MNEMONIC, + metadata: { legacyEntropySource: keyringId }, + }); - expect(controller.state.entropySources).toStrictEqual({}); + expect(Object.keys(controller.state.entropySources)).toHaveLength(1); + expect(controller.state.entropySources[expectedId]).toBeDefined(); }); + }); - it('replaces the entire entropySources map on each sync', async () => { + describe('unregisterSource', () => { + it('removes all entropy sources belonging to the given keyring', async () => { const keyringId = 'hd-keyring-id'; const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); - const keyringStubs = [ - { - type: 'HD Key Tree', - metadata: { id: keyringId, name: '' }, - mnemonic: HD_MNEMONIC, - accounts: [{ address: '0xabc' }], - }, - ]; const { controller, rootMessenger } = await setup({ options: { state: { entropySources: { - 'stale-id': { + [expectedId]: { type: 'bip44:srp', - metadata: { legacyEntropySource: 'old-keyring-id' }, + metadata: { legacyEntropySource: keyringId }, + }, + 'other-entropy-id': { + type: 'bip44:srp', + metadata: { legacyEntropySource: 'other-keyring-id' }, }, }, }, }, - keyrings: keyringStubs, - }); - - publishKeyringStateChange(rootMessenger, [ - { - type: 'HD Key Tree', - accounts: ['0xabc'], - metadata: { id: keyringId, name: '' }, - }, - ]); - await flushPromises(); - - expect(controller.state.entropySources).toStrictEqual({ - [expectedId]: { - type: 'bip44:srp', - metadata: { legacyEntropySource: keyringId }, - }, - }); - expect(controller.state.entropySources['stale-id']).toBeUndefined(); - }); - - it('does nothing when the vault is locked', async () => { - const keyringId = 'hd-keyring-id'; - const keyringStubs = [ - { - type: 'HD Key Tree', - metadata: { id: keyringId, name: '' }, - mnemonic: HD_MNEMONIC, - accounts: [{ address: '0xabc' }], - }, - ]; - - const { controller, rootMessenger, mocks } = await setup({ - keyrings: keyringStubs, - }); - - mocks.KeyringController.getState.mockReturnValueOnce({ - keyrings: [], - isUnlocked: false, - }); - - publishKeyringStateChange(rootMessenger, [ - { - type: 'HD Key Tree', - accounts: ['0xabc'], - metadata: { id: keyringId, name: '' }, - }, - ]); - await flushPromises(); - - expect(controller.state.entropySources).toStrictEqual({}); - }); - - it('synchronizes automatically when keyrings state changes', async () => { - const keyringId = 'hd-keyring-id'; - const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); - - const { controller, rootMessenger } = await setup({ - keyrings: [ - { - type: 'HD Key Tree', - metadata: { id: keyringId, name: '' }, - mnemonic: HD_MNEMONIC, - accounts: [{ address: '0xabc' }], - }, - ], - }); - - publishKeyringStateChange(rootMessenger, [ - { - type: 'HD Key Tree', - accounts: ['0xabc'], - metadata: { id: keyringId, name: '' }, - }, - ]); - await flushPromises(); - - expect(controller.state.entropySources).toStrictEqual({ - [expectedId]: { - type: 'bip44:srp', - metadata: { legacyEntropySource: keyringId }, - }, - }); - }); - - it('synchronizes when the vault unlocks (keyrings go from empty to populated)', async () => { - // Unlocking populates state.keyrings, which triggers the stateChange - // selector and kicks off a sync — no separate unlock subscription needed. - const keyringId = 'hd-keyring-id'; - const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); - - const { controller, rootMessenger } = await setup({ - keyrings: [ - { - type: 'HD Key Tree', - metadata: { id: keyringId, name: '' }, - mnemonic: HD_MNEMONIC, - accounts: [{ address: '0xabc' }], - }, - ], }); - // Simulate unlock: keyrings transition from [] to the restored set. - publishKeyringStateChange(rootMessenger, [ - { - type: 'HD Key Tree', - accounts: ['0xabc'], - metadata: { id: keyringId, name: '' }, - }, - ]); - await flushPromises(); + rootMessenger.call('EntropyController:unregisterSource', keyringId); - expect(controller.state.entropySources).toStrictEqual({ - [expectedId]: { - type: 'bip44:srp', - metadata: { legacyEntropySource: keyringId }, - }, - }); + expect(controller.state.entropySources[expectedId]).toBeUndefined(); + expect( + controller.state.entropySources['other-entropy-id'], + ).toBeDefined(); }); - it('does not synchronize when unrelated state changes', async () => { - const keyringId = 'hd-keyring-id'; - - const { controller, rootMessenger, mocks } = await setup({ - keyrings: [ - { - type: 'HD Key Tree', - metadata: { id: keyringId, name: '' }, - mnemonic: HD_MNEMONIC, - accounts: [{ address: '0xabc' }], - }, - ], - }); + it('does nothing when no sources match the keyring', async () => { + const { controller, rootMessenger } = await setup(); - const keyrings = [ - { type: 'HD Key Tree', accounts: ['0xabc'], metadata: { id: keyringId, name: '' } }, - ]; - - // First publish establishes the selector baseline. - publishKeyringStateChange(rootMessenger, keyrings); - await flushPromises(); - - mocks.KeyringController.withKeyringV2Unsafe.mockClear(); - - // Second publish with the SAME keyrings reference — selector equality prevents re-trigger. - rootMessenger.publish( - 'KeyringController:stateChange', - { keyrings } as never, - [], + rootMessenger.call( + 'EntropyController:unregisterSource', + 'unknown-keyring-id', ); - await flushPromises(); - expect(mocks.KeyringController.withKeyringV2Unsafe).not.toHaveBeenCalled(); + expect(controller.state.entropySources).toStrictEqual({}); }); }); diff --git a/packages/entropy-controller/src/entropy-controller.ts b/packages/entropy-controller/src/entropy-controller.ts index 9215d48036e..b1f9e54b9ce 100644 --- a/packages/entropy-controller/src/entropy-controller.ts +++ b/packages/entropy-controller/src/entropy-controller.ts @@ -4,19 +4,10 @@ import type { StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import type { HdKeyring } from '@metamask/eth-hd-keyring/v2'; -import type { SimpleKeyring } from '@metamask/eth-simple-keyring/v2'; -import type { - KeyringControllerGetStateAction, - KeyringControllerStateChangeEvent, - KeyringControllerWithKeyringV2UnsafeAction, -} from '@metamask/keyring-controller'; -import { KeyringTypes } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; -import { hexToBytes } from '@metamask/utils'; import type { EntropyId, EntropyMetadata, EntropyType } from './types'; -import { isKeyringOwningEntropy, toEntropyId } from './utils'; +import { toEntropyId } from './utils'; /** * The name of the {@link EntropyController}, used to namespace the @@ -84,16 +75,45 @@ export type EntropyControllerGetStateAction = ControllerGetStateAction< >; /** - * Actions that {@link EntropyControllerMessenger} exposes to other consumers. + * Derives and registers a single entropy source. + * + * Called by `KeyringController` after a keyring is successfully persisted, + * so that the entropy ID is available atomically from the caller's perspective. */ -export type EntropyControllerActions = EntropyControllerGetStateAction; +export type EntropyControllerRegisterSourceAction = { + type: `${typeof CONTROLLER_NAME}:registerSource`; + handler: ( + source: + | { + type: 'bip44:srp'; + mnemonic: Uint8Array; + metadata: EntropyMetadata; + } + | { + type: 'raw:private-key'; + privateKey: Uint8Array; + metadata: EntropyMetadata; + }, + ) => Promise; +}; /** - * Actions from other messengers that {@link EntropyControllerMessenger} calls. + * Removes all entropy sources associated with a given keyring ID. + * + * Called by `KeyringController` after a keyring is removed. */ -type AllowedActions = - | KeyringControllerGetStateAction - | KeyringControllerWithKeyringV2UnsafeAction; +export type EntropyControllerUnregisterSourceAction = { + type: `${typeof CONTROLLER_NAME}:unregisterSource`; + handler: (keyringId: string) => void; +}; + +/** + * Actions that {@link EntropyControllerMessenger} exposes to other consumers. + */ +export type EntropyControllerActions = + | EntropyControllerGetStateAction + | EntropyControllerRegisterSourceAction + | EntropyControllerUnregisterSourceAction; /** * Published when the state of {@link EntropyController} changes. @@ -108,20 +128,14 @@ export type EntropyControllerStateChangeEvent = ControllerStateChangeEvent< */ export type EntropyControllerEvents = EntropyControllerStateChangeEvent; -/** - * Events from other messengers that {@link EntropyControllerMessenger} - * subscribes to. - */ -type AllowedEvents = KeyringControllerStateChangeEvent; - /** * The messenger restricted to actions and events accessed by * {@link EntropyController}. */ export type EntropyControllerMessenger = Messenger< typeof CONTROLLER_NAME, - EntropyControllerActions | AllowedActions, - EntropyControllerEvents | AllowedEvents + EntropyControllerActions, + EntropyControllerEvents >; /** @@ -133,6 +147,13 @@ export type EntropyControllerMessenger = Messenger< * registry is the single source of truth for which entropy sources exist and * what kind they are. Actual key material and signing operations are left to * entropy source implementations and chain-specific keyrings. + * + * Rather than reacting to `KeyringController` state changes, this controller + * exposes `registerSource` and `unregisterSource` actions that `KeyringController` + * calls directly after its own operations succeed. This ensures that entropy + * sources are registered atomically from the caller's perspective — by the time + * `addNewKeyring` or `submitPassword` resolves, the corresponding entropy IDs + * are already present in state. */ export class EntropyController extends BaseController< typeof CONTROLLER_NAME, @@ -164,110 +185,59 @@ export class EntropyController extends BaseController< }, }); - // Syncing on `keyrings` state changes covers all cases, including vault - // unlock: unlocking populates `state.keyrings`, which triggers this - // selector and kicks off a sync automatically. - this.messenger.subscribe( - 'KeyringController:stateChange', - () => { - this.#syncEntropies().catch(console.error); - }, - (state) => state.keyrings, + this.messenger.registerActionHandler( + `${CONTROLLER_NAME}:registerSource`, + this.#registerSource.bind(this), ); - } - async #syncEntropies(): Promise { - const { isUnlocked, keyrings } = this.messenger.call( - 'KeyringController:getState', + this.messenger.registerActionHandler( + `${CONTROLLER_NAME}:unregisterSource`, + this.#unregisterSource.bind(this), ); - if (!isUnlocked) { - return; - } - - const entropySources: EntropyControllerState['entropySources'] = {}; - for (const keyring of keyrings.filter(isKeyringOwningEntropy)) { - const { id } = keyring.metadata; - - if (keyring.type === KeyringTypes.hd) { - await this.#syncHdKeyring(id, entropySources); - } else if (keyring.type === KeyringTypes.simple) { - await this.#syncSimpleKeyring(id, entropySources); - } - } - - this.update((state) => { - state.entropySources = entropySources; - }); } /** - * Derives the entropy source entry for a single HD keyring and adds it to - * the given map. - * - * Reads the mnemonic via `withKeyringV2Unsafe` and derives a stable - * `EntropyId` with type `'bip44:srp'`. Skips the keyring silently if the - * mnemonic is not yet initialised. + * Derives and merges a single entropy source into state. * - * @param id - The keyring metadata ID. - * @param sources - The map to populate in-place. + * @param source - The entropy source to register, including raw bytes and + * the metadata that links it back to its originating keyring. */ - async #syncHdKeyring( - id: string, - sources: EntropyControllerState['entropySources'], - ): Promise { - await this.messenger.call( - 'KeyringController:withKeyringV2Unsafe', - { id }, - async ({ keyring }) => { - const hdKeyring = keyring as HdKeyring; - if (!hdKeyring.mnemonic) { - return; + async #registerSource( + source: + | { + type: 'bip44:srp'; + mnemonic: Uint8Array; + metadata: EntropyMetadata; } - const entropyId = await toEntropyId(hdKeyring.mnemonic, 'bip44:srp'); - sources[entropyId] = { - type: 'bip44:srp', - metadata: { legacyEntropySource: id }, - }; - }, - ); + | { + type: 'raw:private-key'; + privateKey: Uint8Array; + metadata: EntropyMetadata; + }, + ): Promise { + const bytes = source.type === 'bip44:srp' ? source.mnemonic : source.privateKey; + const entropyId = await toEntropyId(bytes, source.type); + this.update((state) => { + state.entropySources[entropyId] = { + type: source.type, + metadata: source.metadata, + }; + }); } /** - * Derives entropy source entries for a single Simple keyring and adds them - * to the given map. - * - * Enumerates accounts via `withKeyringV2Unsafe`, exports each private key - * via `exportAccount`, and derives a stable `EntropyId` with type - * `'raw:private-key'` for each one. + * Removes all entropy sources whose `legacyEntropySource` matches the given + * keyring ID. * - * @param id - The keyring metadata ID. - * @param sources - The map to populate in-place. + * @param keyringId - The ID of the keyring being removed. */ - async #syncSimpleKeyring( - id: string, - sources: EntropyControllerState['entropySources'], - ): Promise { - await this.messenger.call( - 'KeyringController:withKeyringV2Unsafe', - { id }, - async ({ keyring }) => { - const simpleKeyring = keyring as SimpleKeyring; - const accounts = await simpleKeyring.getAccounts(); - for (const account of accounts) { - const exported = await simpleKeyring.exportAccount(account.id, { - type: 'private-key', - encoding: 'hexadecimal', - }); - const entropyId = await toEntropyId( - hexToBytes(exported.privateKey), - 'raw:private-key', - ); - sources[entropyId] = { - type: 'raw:private-key', - metadata: { legacyEntropySource: id }, - }; + #unregisterSource(keyringId: string): void { + this.update((state) => { + for (const [entropyId, entry] of Object.entries(state.entropySources)) { + if (entry.metadata.legacyEntropySource === keyringId) { + delete state.entropySources[entropyId]; } - }, - ); + } + }); } } diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index 1052dd7b082..bc06f687427 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -42,11 +42,9 @@ import { buildMockTransaction } from '../tests/mocks/mockTransaction'; import { KeyringControllerErrorMessage } from './constants'; import { KeyringControllerError } from './errors'; import type { - KeyringControllerEvents, KeyringControllerMessenger, KeyringControllerState, KeyringControllerOptions, - KeyringControllerActions, KeyringMetadata, SerializedKeyring, KeyringSelector, @@ -6155,7 +6153,16 @@ function stubKeyringClassWithAccount( * @returns The root messenger. */ function buildRootMessenger(): RootMessenger { - return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); + const messenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE }); + messenger.registerActionHandler( + 'EntropyController:registerSource', + jest.fn().mockResolvedValue(undefined), + ); + messenger.registerActionHandler( + 'EntropyController:unregisterSource', + jest.fn(), + ); + return messenger; } /** @@ -6167,18 +6174,23 @@ function buildRootMessenger(): RootMessenger { */ function buildKeyringControllerMessenger( messenger = buildRootMessenger(), -): Messenger< - 'KeyringController', - KeyringControllerActions, - KeyringControllerEvents, - typeof messenger -> { - return new Messenger< +): KeyringControllerMessenger { + const kcMessenger = new Messenger< 'KeyringController', - KeyringControllerActions, - KeyringControllerEvents, + AllKeyringControllerActions, + AllKeyringControllerEvents, typeof messenger >({ namespace: 'KeyringController', parent: messenger }); + + messenger.delegate({ + messenger: kcMessenger, + actions: [ + 'EntropyController:registerSource', + 'EntropyController:unregisterSource', + ], + }); + + return kcMessenger as KeyringControllerMessenger; } /** diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index 3f9351b594b..45c58bf090d 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -196,9 +196,34 @@ export type KeyringControllerEvents = | KeyringControllerUnlockEvent | KeyringControllerAccountRemovedEvent; +// Inline types to avoid a circular package dependency with entropy-controller. +type EntropyControllerRegisterSourceAction = { + type: 'EntropyController:registerSource'; + handler: ( + source: + | { + type: 'bip44:srp'; + mnemonic: Uint8Array; + metadata: { legacyEntropySource: string }; + } + | { + type: 'raw:private-key'; + privateKey: Uint8Array; + metadata: { legacyEntropySource: string }; + }, + ) => Promise; +}; + +type EntropyControllerUnregisterSourceAction = { + type: 'EntropyController:unregisterSource'; + handler: (keyringId: string) => void; +}; + export type KeyringControllerMessenger = Messenger< typeof name, - KeyringControllerActions, + | KeyringControllerActions + | EntropyControllerRegisterSourceAction + | EntropyControllerUnregisterSourceAction, KeyringControllerEvents >; @@ -1029,6 +1054,10 @@ export class KeyringController< const keyring = await this.#persistOrRollback(async () => this.#newKeyring(type, opts), ); + const entry = this.#keyrings.find((ke) => ke.keyring === keyring); + if (entry) { + await this.#registerEntropySource(entry); + } return this.#getKeyringMetadata(keyring); } @@ -1340,7 +1369,9 @@ export class KeyringController< ): Promise { this.#assertIsUnlocked(); - return this.#persistOrRollback(async () => { + let capturedKeyring: EthKeyring | undefined; + + const result = await this.#persistOrRollback(async () => { let privateKey; switch (strategy) { case AccountImportStrategy.privateKey: { @@ -1391,9 +1422,19 @@ export class KeyringController< const newKeyring = await this.#newKeyring(KeyringTypes.simple, [ privateKey, ]); + capturedKeyring = newKeyring; const accounts = await newKeyring.getAccounts(); return accounts[0]; }); + + if (capturedKeyring) { + const entry = this.#keyrings.find((ke) => ke.keyring === capturedKeyring); + if (entry) { + await this.#registerEntropySource(entry); + } + } + + return result; } /** @@ -1406,6 +1447,8 @@ export class KeyringController< async removeAccount(address: string): Promise { this.#assertIsUnlocked(); + let removedKeyringId: string | undefined; + await this.#persistOrRollback(async () => { const keyringIndex = await this.#findKeyringIndexForAccount(address); @@ -1444,12 +1487,16 @@ export class KeyringController< await keyring.removeAccount(address as Hex); if (shouldRemoveKeyring) { + removedKeyringId = this.#getKeyringMetadata(keyring).id; this.#keyrings.splice(keyringIndex, 1); await this.#destroyKeyring(keyring, keyringV2); } }); this.messenger.publish(`${name}:accountRemoved`, address); + if (removedKeyringId) { + this.messenger.call('EntropyController:unregisterSource', removedKeyringId); + } } /** @@ -1777,6 +1824,10 @@ export class KeyringController< // since the controller is already unlocked. console.error('Failed to update vault during login:', error); } + + for (const entry of this.#keyrings) { + await this.#registerEntropySource(entry); + } } /** @@ -1827,6 +1878,10 @@ export class KeyringController< // since the controller is already unlocked. console.error('Failed to update vault during login:', error); } + + for (const entry of this.#keyrings) { + await this.#registerEntropySource(entry); + } } /** @@ -2208,10 +2263,10 @@ export class KeyringController< ): Promise { this.#assertIsUnlocked(); - return this.#persistOrRollback(async () => { - // Track created and removed keyrings during the operation execution. - const createdEntries = new Set(); - const removedEntries = new Set(); + const createdEntries = new Set(); + const removedEntries = new Set(); + + const result = await this.#persistOrRollback(async () => { // Copy of the current keyrings that is mutated during the operation execution. const restrictedEntries = [...this.#keyrings]; @@ -2271,9 +2326,9 @@ export class KeyringController< ); }; - let result: CallbackResult; + let callbackResult: CallbackResult; try { - result = await operation(restrictedController); + callbackResult = await operation(restrictedController); } catch (error) { await destroyKeyrings(createdEntries); @@ -2294,14 +2349,23 @@ export class KeyringController< // still have references to them. ...removedEntries, ]) { - this.#assertNoUnsafeDirectKeyringAccess(result, keyring); + this.#assertNoUnsafeDirectKeyringAccess(callbackResult, keyring); if (keyringV2) { - this.#assertNoUnsafeDirectKeyringAccess(result, keyringV2); + this.#assertNoUnsafeDirectKeyringAccess(callbackResult, keyringV2); } } - return result; + return callbackResult; }); + + for (const entry of createdEntries) { + await this.#registerEntropySource(entry); + } + for (const { metadata } of removedEntries) { + this.messenger.call('EntropyController:unregisterSource', metadata.id); + } + + return result; } /** @@ -2317,6 +2381,50 @@ export class KeyringController< return keyring.type; } + /** + * Registers entropy sources for a single keyring entry with + * `EntropyController`. Called after each successful persist operation. + * + * HD keyrings contribute one `bip44:srp` source (the mnemonic). + * Simple keyrings contribute one `raw:private-key` source per account, + * retrieved via `exportAccount` on the v2 keyring interface. + * + * The call is fire-and-forget from an error perspective — if `EntropyController` + * is not wired up in the current consumer, the messenger call throws and the + * error propagates. Consumers that do not use `EntropyController` should not + * wire `KeyringControllerMessenger` with these `AllowedActions`. + * + * @param entry - The keyring entry to register. + */ + async #registerEntropySource(entry: KeyringEntry): Promise { + const { keyring, keyringV2, metadata } = entry; + + if (keyring.type === KeyringTypes.hd && keyringV2) { + const hdKeyring = keyringV2 as HdKeyringV2; + if (hdKeyring.mnemonic) { + await this.messenger.call('EntropyController:registerSource', { + type: 'bip44:srp', + mnemonic: hdKeyring.mnemonic, + metadata: { legacyEntropySource: metadata.id }, + }); + } + } else if (keyring.type === KeyringTypes.simple && keyringV2) { + const simpleKeyring = keyringV2 as SimpleKeyringV2; + const accounts = await simpleKeyring.getAccounts(); + for (const account of accounts) { + const exported = await simpleKeyring.exportAccount(account.id, { + type: 'private-key', + encoding: 'hexadecimal', + }); + await this.messenger.call('EntropyController:registerSource', { + type: 'raw:private-key', + privateKey: hexToBytes(add0x(exported.privateKey)), + metadata: { legacyEntropySource: metadata.id }, + }); + } + } + } + /** * Constructor helper for registering this controller's messeger * actions. diff --git a/packages/wallet/src/initialization/instances/entropy-controller/entropy-controller.ts b/packages/wallet/src/initialization/instances/entropy-controller/entropy-controller.ts index c798db82b4a..057f1e9be46 100644 --- a/packages/wallet/src/initialization/instances/entropy-controller/entropy-controller.ts +++ b/packages/wallet/src/initialization/instances/entropy-controller/entropy-controller.ts @@ -21,23 +21,9 @@ export const entropyController: InitializationConfiguration< state, messenger, }), - getMessenger: (parent: RootMessenger) => { - const entropyControllerMessenger: EntropyControllerMessenger = new Messenger( - { - namespace: 'EntropyController', - parent, - }, - ); - - parent.delegate({ - messenger: entropyControllerMessenger, - actions: [ - 'KeyringController:getState', - 'KeyringController:withKeyringV2Unsafe', - ], - events: ['KeyringController:stateChange'], - }); - - return entropyControllerMessenger; - }, + getMessenger: (parent: RootMessenger) => + new Messenger({ + namespace: 'EntropyController', + parent, + }) as EntropyControllerMessenger, }; From 9163d1158eca46cd8296b246de79e9f0c90dda5b Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 22 Jul 2026 18:24:08 +0200 Subject: [PATCH 17/19] refactor: add {add,removeEntropy} + use them in the KeyringController --- .../src/entropy-controller.test.ts | 134 +++++----------- .../src/entropy-controller.ts | 114 ++++---------- packages/entropy-controller/src/index.ts | 9 +- packages/entropy-controller/src/types.test.ts | 21 +++ packages/entropy-controller/src/types.ts | 54 +++++-- packages/entropy-controller/src/utils.test.ts | 113 ++++++------- packages/entropy-controller/src/utils.ts | 93 ++++------- packages/keyring-controller/package.json | 1 + .../src/KeyringController.test.ts | 10 +- .../src/KeyringController.ts | 148 +++++++++--------- .../keyring-controller/tsconfig.build.json | 3 + packages/keyring-controller/tsconfig.json | 3 + yarn.lock | 1 + 13 files changed, 330 insertions(+), 374 deletions(-) create mode 100644 packages/entropy-controller/src/types.test.ts diff --git a/packages/entropy-controller/src/entropy-controller.test.ts b/packages/entropy-controller/src/entropy-controller.test.ts index 6649d5c53b8..bc6a1aa758d 100644 --- a/packages/entropy-controller/src/entropy-controller.test.ts +++ b/packages/entropy-controller/src/entropy-controller.test.ts @@ -14,11 +14,6 @@ import { toEntropyId } from './utils'; const HD_MNEMONIC = new TextEncoder().encode( 'test test test test test test test test test test test junk', ); -const PRIVATE_KEY_BYTES = Uint8Array.from( - '4af1bceebf7f3634ec3cff8a2c38e51178d5d4ce585c52d6043e5e2cc3f1d3e1' - .match(/../gu)! - .map((b) => parseInt(b, 16)), -); type RootMessenger = Messenger< MockAnyNamespace, @@ -57,9 +52,8 @@ describe('EntropyController', () => { it('accepts initial state', async () => { const givenState = { entropySources: { - 'entropy-1': { - type: 'bip44:srp' as const, - metadata: { legacyEntropySource: 'keyring-1' }, + 'entropy:bip44:mnemonic:some-uuid': { + type: 'bip44:mnemonic' as const, }, }, }; @@ -80,141 +74,95 @@ describe('EntropyController', () => { }); }); - describe('registerSource', () => { - it('registers an HD keyring entropy source', async () => { - const keyringId = 'hd-keyring-id'; - const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); - - const { controller, rootMessenger } = await setup(); - - await rootMessenger.call('EntropyController:registerSource', { - type: 'bip44:srp', - mnemonic: HD_MNEMONIC, - metadata: { legacyEntropySource: keyringId }, - }); - - expect(controller.state.entropySources).toStrictEqual({ - [expectedId]: { - type: 'bip44:srp', - metadata: { legacyEntropySource: keyringId }, - }, - }); - }); - - it('registers a Simple keyring entropy source', async () => { - const keyringId = 'simple-keyring-id'; - const expectedId = await toEntropyId(PRIVATE_KEY_BYTES, 'raw:private-key'); + describe('addEntropy', () => { + it('registers a mnemonic entropy source', async () => { + const expectedId = await toEntropyId('bip44', 'mnemonic', HD_MNEMONIC); const { controller, rootMessenger } = await setup(); - await rootMessenger.call('EntropyController:registerSource', { - type: 'raw:private-key', - privateKey: PRIVATE_KEY_BYTES, - metadata: { legacyEntropySource: keyringId }, + rootMessenger.call('EntropyController:addEntropy', { + type: 'bip44:mnemonic', + id: expectedId, }); expect(controller.state.entropySources).toStrictEqual({ - [expectedId]: { - type: 'raw:private-key', - metadata: { legacyEntropySource: keyringId }, - }, + [expectedId]: { type: 'bip44:mnemonic' }, }); }); it('merges new sources without replacing existing ones', async () => { - const hdKeyringId = 'hd-keyring-id'; - const simpleKeyringId = 'simple-keyring-id'; - const expectedHdId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); - const expectedSimpleId = await toEntropyId( - PRIVATE_KEY_BYTES, - 'raw:private-key', + const firstMnemonic = HD_MNEMONIC; + const secondMnemonic = new TextEncoder().encode( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', ); + const firstId = await toEntropyId('bip44', 'mnemonic', firstMnemonic); + const secondId = await toEntropyId('bip44', 'mnemonic', secondMnemonic); const { controller, rootMessenger } = await setup(); - await rootMessenger.call('EntropyController:registerSource', { - type: 'bip44:srp', - mnemonic: HD_MNEMONIC, - metadata: { legacyEntropySource: hdKeyringId }, + rootMessenger.call('EntropyController:addEntropy', { + type: 'bip44:mnemonic', + id: firstId, }); - - await rootMessenger.call('EntropyController:registerSource', { - type: 'raw:private-key', - privateKey: PRIVATE_KEY_BYTES, - metadata: { legacyEntropySource: simpleKeyringId }, + rootMessenger.call('EntropyController:addEntropy', { + type: 'bip44:mnemonic', + id: secondId, }); expect(controller.state.entropySources).toStrictEqual({ - [expectedHdId]: { - type: 'bip44:srp', - metadata: { legacyEntropySource: hdKeyringId }, - }, - [expectedSimpleId]: { - type: 'raw:private-key', - metadata: { legacyEntropySource: simpleKeyringId }, - }, + [firstId]: { type: 'bip44:mnemonic' }, + [secondId]: { type: 'bip44:mnemonic' }, }); }); it('is idempotent — registering the same source twice yields one entry', async () => { - const keyringId = 'hd-keyring-id'; - const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); + const id = await toEntropyId('bip44', 'mnemonic', HD_MNEMONIC); const { controller, rootMessenger } = await setup(); - await rootMessenger.call('EntropyController:registerSource', { - type: 'bip44:srp', - mnemonic: HD_MNEMONIC, - metadata: { legacyEntropySource: keyringId }, + rootMessenger.call('EntropyController:addEntropy', { + type: 'bip44:mnemonic', + id, }); - - await rootMessenger.call('EntropyController:registerSource', { - type: 'bip44:srp', - mnemonic: HD_MNEMONIC, - metadata: { legacyEntropySource: keyringId }, + rootMessenger.call('EntropyController:addEntropy', { + type: 'bip44:mnemonic', + id, }); expect(Object.keys(controller.state.entropySources)).toHaveLength(1); - expect(controller.state.entropySources[expectedId]).toBeDefined(); + expect(controller.state.entropySources[id]).toBeDefined(); }); }); - describe('unregisterSource', () => { - it('removes all entropy sources belonging to the given keyring', async () => { - const keyringId = 'hd-keyring-id'; - const expectedId = await toEntropyId(HD_MNEMONIC, 'bip44:srp'); + describe('removeEntropy', () => { + it('removes an entropy source by its ID', async () => { + const id = await toEntropyId('bip44', 'mnemonic', HD_MNEMONIC); const { controller, rootMessenger } = await setup({ options: { state: { entropySources: { - [expectedId]: { - type: 'bip44:srp', - metadata: { legacyEntropySource: keyringId }, - }, - 'other-entropy-id': { - type: 'bip44:srp', - metadata: { legacyEntropySource: 'other-keyring-id' }, - }, + [id]: { type: 'bip44:mnemonic' }, + 'entropy:bip44:mnemonic:other-uuid': { type: 'bip44:mnemonic' }, }, }, }, }); - rootMessenger.call('EntropyController:unregisterSource', keyringId); + rootMessenger.call('EntropyController:removeEntropy', id); - expect(controller.state.entropySources[expectedId]).toBeUndefined(); + expect(controller.state.entropySources[id]).toBeUndefined(); expect( - controller.state.entropySources['other-entropy-id'], + controller.state.entropySources['entropy:bip44:mnemonic:other-uuid'], ).toBeDefined(); }); - it('does nothing when no sources match the keyring', async () => { + it('does nothing when the ID does not exist', async () => { const { controller, rootMessenger } = await setup(); rootMessenger.call( - 'EntropyController:unregisterSource', - 'unknown-keyring-id', + 'EntropyController:removeEntropy', + 'entropy:bip44:mnemonic:nonexistent', ); expect(controller.state.entropySources).toStrictEqual({}); diff --git a/packages/entropy-controller/src/entropy-controller.ts b/packages/entropy-controller/src/entropy-controller.ts index b1f9e54b9ce..b672b29b8c0 100644 --- a/packages/entropy-controller/src/entropy-controller.ts +++ b/packages/entropy-controller/src/entropy-controller.ts @@ -6,8 +6,7 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; -import type { EntropyId, EntropyMetadata, EntropyType } from './types'; -import { toEntropyId } from './utils'; +import type { Entropy, EntropyId, EntropyType } from './types'; /** * The name of the {@link EntropyController}, used to namespace the @@ -22,20 +21,15 @@ const CONTROLLER_NAME = 'EntropyController'; export type EntropyControllerState = { /** * The registry of entropy sources, keyed by their unique identifier. - * Each entry records the type and metadata of the entropy source. + * Each entry records the type of the entropy source. */ entropySources: { [entropyId: EntropyId]: { /** * The type of the entropy source, expressed as `category:implementation` - * (e.g. `'bip44:srp'`, `'bip44:ledger'`). + * (e.g. `'bip44:mnemonic'`, `'bip44:ledger'`). */ type: EntropyType; - - /** - * Metadata associated with the entropy source. - */ - metadata: EntropyMetadata; }; }; }; @@ -53,10 +47,7 @@ const entropyControllerMetadata = { } satisfies StateMetadata; /** - * Constructs the default {@link EntropyController} state. This allows - * consumers to provide a partial state object when initializing the controller - * and also helps in constructing complete state objects for this controller in - * tests. + * Constructs the default {@link EntropyController} state. * * @returns The default {@link EntropyController} state. */ @@ -75,36 +66,22 @@ export type EntropyControllerGetStateAction = ControllerGetStateAction< >; /** - * Derives and registers a single entropy source. + * Registers an entropy source in the controller. * - * Called by `KeyringController` after a keyring is successfully persisted, - * so that the entropy ID is available atomically from the caller's perspective. + * The caller is responsible for pre-computing the entropy ID via `toEntropyId` + * before calling this action. */ -export type EntropyControllerRegisterSourceAction = { - type: `${typeof CONTROLLER_NAME}:registerSource`; - handler: ( - source: - | { - type: 'bip44:srp'; - mnemonic: Uint8Array; - metadata: EntropyMetadata; - } - | { - type: 'raw:private-key'; - privateKey: Uint8Array; - metadata: EntropyMetadata; - }, - ) => Promise; +export type EntropyControllerAddEntropyAction = { + type: `${typeof CONTROLLER_NAME}:addEntropy`; + handler: (entropy: Entropy) => void; }; /** - * Removes all entropy sources associated with a given keyring ID. - * - * Called by `KeyringController` after a keyring is removed. + * Removes an entropy source by its ID. */ -export type EntropyControllerUnregisterSourceAction = { - type: `${typeof CONTROLLER_NAME}:unregisterSource`; - handler: (keyringId: string) => void; +export type EntropyControllerRemoveEntropyAction = { + type: `${typeof CONTROLLER_NAME}:removeEntropy`; + handler: (entropyId: EntropyId) => void; }; /** @@ -112,8 +89,8 @@ export type EntropyControllerUnregisterSourceAction = { */ export type EntropyControllerActions = | EntropyControllerGetStateAction - | EntropyControllerRegisterSourceAction - | EntropyControllerUnregisterSourceAction; + | EntropyControllerAddEntropyAction + | EntropyControllerRemoveEntropyAction; /** * Published when the state of {@link EntropyController} changes. @@ -140,20 +117,17 @@ export type EntropyControllerMessenger = Messenger< /** * `EntropyController` maintains a registry that maps entropy source identifiers - * to their type and metadata. + * to their type. * * An entropy source is any provider of key material: a Secret Recovery Phrase * (SRP), a hardware wallet (Ledger, Trezor), or an imported private key. The * registry is the single source of truth for which entropy sources exist and - * what kind they are. Actual key material and signing operations are left to - * entropy source implementations and chain-specific keyrings. + * what kind they are. * * Rather than reacting to `KeyringController` state changes, this controller - * exposes `registerSource` and `unregisterSource` actions that `KeyringController` + * exposes `addEntropy` and `removeEntropy` actions that `KeyringController` * calls directly after its own operations succeed. This ensures that entropy - * sources are registered atomically from the caller's perspective — by the time - * `addNewKeyring` or `submitPassword` resolves, the corresponding entropy IDs - * are already present in state. + * sources are registered atomically from the caller's perspective. */ export class EntropyController extends BaseController< typeof CONTROLLER_NAME, @@ -186,58 +160,36 @@ export class EntropyController extends BaseController< }); this.messenger.registerActionHandler( - `${CONTROLLER_NAME}:registerSource`, - this.#registerSource.bind(this), + `${CONTROLLER_NAME}:addEntropy`, + this.addEntropy.bind(this), ); this.messenger.registerActionHandler( - `${CONTROLLER_NAME}:unregisterSource`, - this.#unregisterSource.bind(this), + `${CONTROLLER_NAME}:removeEntropy`, + this.removeEntropy.bind(this), ); } /** - * Derives and merges a single entropy source into state. + * Registers an entropy source in the controller state. * - * @param source - The entropy source to register, including raw bytes and - * the metadata that links it back to its originating keyring. + * @param entropy - The entropy source to register, including its pre-computed + * ID and type. */ - async #registerSource( - source: - | { - type: 'bip44:srp'; - mnemonic: Uint8Array; - metadata: EntropyMetadata; - } - | { - type: 'raw:private-key'; - privateKey: Uint8Array; - metadata: EntropyMetadata; - }, - ): Promise { - const bytes = source.type === 'bip44:srp' ? source.mnemonic : source.privateKey; - const entropyId = await toEntropyId(bytes, source.type); + addEntropy(entropy: Entropy): void { this.update((state) => { - state.entropySources[entropyId] = { - type: source.type, - metadata: source.metadata, - }; + state.entropySources[entropy.id] = { type: entropy.type }; }); } /** - * Removes all entropy sources whose `legacyEntropySource` matches the given - * keyring ID. + * Removes an entropy source from the controller state. * - * @param keyringId - The ID of the keyring being removed. + * @param entropyId - The ID of the entropy source to remove. */ - #unregisterSource(keyringId: string): void { + removeEntropy(entropyId: EntropyId): void { this.update((state) => { - for (const [entropyId, entry] of Object.entries(state.entropySources)) { - if (entry.metadata.legacyEntropySource === keyringId) { - delete state.entropySources[entropyId]; - } - } + delete state.entropySources[entropyId]; }); } } diff --git a/packages/entropy-controller/src/index.ts b/packages/entropy-controller/src/index.ts index 8dee35af6f4..425cf492b4d 100644 --- a/packages/entropy-controller/src/index.ts +++ b/packages/entropy-controller/src/index.ts @@ -1,16 +1,21 @@ export { EntropyController } from './entropy-controller'; export type { EntropyControllerActions, + EntropyControllerAddEntropyAction, EntropyControllerEvents, EntropyControllerGetStateAction, EntropyControllerMessenger, + EntropyControllerRemoveEntropyAction, EntropyControllerState, EntropyControllerStateChangeEvent, } from './entropy-controller'; export type { + Bip44MnemonicEntropy, + Entropy, EntropyCategory, EntropyId, - EntropyMetadata, + EntropyImplementation, EntropyType, } from './types'; -export { isKeyringOwningEntropy, toEntropyFingerprint, toEntropyId } from './utils'; +export { isBip44MnemonicEntropy } from './types'; +export { fingerprint, toEntropyFingerprint, toEntropyId } from './utils'; diff --git a/packages/entropy-controller/src/types.test.ts b/packages/entropy-controller/src/types.test.ts new file mode 100644 index 00000000000..f6d8c929c0a --- /dev/null +++ b/packages/entropy-controller/src/types.test.ts @@ -0,0 +1,21 @@ +import { isBip44MnemonicEntropy } from './types'; + +describe('isBip44MnemonicEntropy', () => { + it('returns true for a bip44:mnemonic entropy', () => { + expect( + isBip44MnemonicEntropy({ type: 'bip44:mnemonic', id: 'entropy:bip44:mnemonic:some-uuid' }), + ).toBe(true); + }); + + it('returns false for other bip44 implementations', () => { + expect( + isBip44MnemonicEntropy({ type: 'bip44:ledger', id: 'entropy:bip44:ledger:_' }), + ).toBe(false); + }); + + it('returns false for raw category types', () => { + expect( + isBip44MnemonicEntropy({ type: 'raw:private-key', id: 'entropy:raw:private-key:some-uuid' }), + ).toBe(false); + }); +}); diff --git a/packages/entropy-controller/src/types.ts b/packages/entropy-controller/src/types.ts index 68a7132d502..f554c0f7213 100644 --- a/packages/entropy-controller/src/types.ts +++ b/packages/entropy-controller/src/types.ts @@ -8,28 +8,62 @@ */ export type EntropyCategory = 'bip44' | 'raw'; +/** + * The implementation of an entropy source within its category. + */ +export type EntropyImplementation = + | 'mnemonic' + | 'ledger' + | 'trezor' + | 'private-key' + | 'mpc'; + /** * The type of an entropy source, expressed as `category:implementation`. * - * @example `'bip44:srp'`, `'bip44:ledger'`, `'raw:private-key'`, `'raw:mpc'` + * @example `'bip44:mnemonic'`, `'bip44:ledger'`, `'raw:private-key'`, `'raw:mpc'` */ -export type EntropyType = `${EntropyCategory}:${string}`; +export type EntropyType = `${EntropyCategory}:${EntropyImplementation}`; /** * Unique identifier for an entropy source. + * + * Format: `entropy:{category}:{implementation}:{uuid}` where the UUID is a + * deterministic fingerprint of the underlying secret, or `'_'` for hardware + * wallets whose secret never leaves the device. */ export type EntropyId = string; /** - * Metadata associated with an entropy source. - * - * Currently a placeholder — fields will be added as requirements emerge. + * Represents a source of entropy. */ -export type EntropyMetadata = { +export type Entropy = { + /** + * The unique identifier for this entropy source. + */ + id: EntropyId; + /** - * The ID of the keyring that owns this entropy source. - * Prefixed `legacy` because keyrings are a transitional backing store — - * future entropy sources may not originate from a `KeyringController` keyring. + * The type of this entropy source. */ - legacyEntropySource: string; + type: EntropyType; }; + +/** + * An entropy source backed by a BIP-39 mnemonic (SRP). + */ +export type Bip44MnemonicEntropy = Entropy & { + type: 'bip44:mnemonic'; +}; + +/** + * Type guard for {@link Bip44MnemonicEntropy}. + * + * @param entropy - The entropy source to check. + * @returns `true` if the entropy source is a {@link Bip44MnemonicEntropy}. + */ +export function isBip44MnemonicEntropy( + entropy: Entropy, +): entropy is Bip44MnemonicEntropy { + return entropy.type === 'bip44:mnemonic'; +} diff --git a/packages/entropy-controller/src/utils.test.ts b/packages/entropy-controller/src/utils.test.ts index 5eab9b50a07..3c3449943b5 100644 --- a/packages/entropy-controller/src/utils.test.ts +++ b/packages/entropy-controller/src/utils.test.ts @@ -1,91 +1,98 @@ -import type { KeyringObject } from '@metamask/keyring-controller'; - -import { isKeyringOwningEntropy, toEntropyFingerprint, toEntropyId } from './utils'; +import { fingerprint, toEntropyFingerprint, toEntropyId } from './utils'; const SECRET = new Uint8Array(32).fill(1); -describe('isKeyringOwningEntropy', () => { - it.each([ - { type: 'HD Key Tree', expected: true }, - { type: 'Simple Key Pair', expected: true }, - { type: 'Snap Keyring', expected: false }, - { type: 'Ledger Hardware', expected: false }, - { type: 'Trezor Hardware', expected: false }, - ])('returns $expected for type "$type"', ({ type, expected }) => { - const keyring = { - type, - accounts: [], - metadata: { id: 'test-id', name: '' }, - } as KeyringObject; - expect(isKeyringOwningEntropy(keyring)).toBe(expected); +describe('fingerprint', () => { + it('returns a valid UUID v4 string', async () => { + const fp = await fingerprint(SECRET); + expect(fp).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ); + }); + + it('is deterministic — same input always produces the same fingerprint', async () => { + const first = await fingerprint(SECRET); + const second = await fingerprint(SECRET); + expect(first).toBe(second); + }); + + it('produces different fingerprints for different secrets', async () => { + const otherSecret = new Uint8Array(32).fill(2); + const first = await fingerprint(SECRET); + const second = await fingerprint(otherSecret); + expect(first).not.toBe(second); + }); + + it('matches a known value to guard against accidental algorithm changes', async () => { + // uuid({ random: HMAC-SHA256( key=Uint8Array(32).fill(1), msg='metamask:fingerprint' ).slice(0, 16) }) + const fp = await fingerprint(SECRET); + expect(fp).toBe('29b22736-01c7-4096-9ae5-9f735d55b5a2'); }); }); describe('toEntropyFingerprint', () => { it('returns a 64-character lowercase hex string', async () => { - const fingerprint = await toEntropyFingerprint(SECRET, 'bip44:srp'); - expect(fingerprint).toHaveLength(64); - expect(fingerprint).toMatch(/^[0-9a-f]+$/u); + const fp = await toEntropyFingerprint(SECRET); + expect(fp).toHaveLength(64); + expect(fp).toMatch(/^[0-9a-f]+$/u); }); - it('is deterministic — same inputs always produce the same fingerprint', async () => { - const first = await toEntropyFingerprint(SECRET, 'bip44:srp'); - const second = await toEntropyFingerprint(SECRET, 'bip44:srp'); + it('is deterministic — same input always produces the same fingerprint', async () => { + const first = await toEntropyFingerprint(SECRET); + const second = await toEntropyFingerprint(SECRET); expect(first).toBe(second); }); - it('is type-scoped — different entropy types produce different fingerprints for the same secret', async () => { - const srpFingerprint = await toEntropyFingerprint(SECRET, 'bip44:srp'); - const pkFingerprint = await toEntropyFingerprint(SECRET, 'raw:private-key'); - expect(srpFingerprint).not.toBe(pkFingerprint); - }); - - it('produces different fingerprints for different secrets of the same type', async () => { + it('produces different fingerprints for different secrets', async () => { const otherSecret = new Uint8Array(32).fill(2); - const first = await toEntropyFingerprint(SECRET, 'bip44:srp'); - const second = await toEntropyFingerprint(otherSecret, 'bip44:srp'); + const first = await toEntropyFingerprint(SECRET); + const second = await toEntropyFingerprint(otherSecret); expect(first).not.toBe(second); }); it('matches a known value to guard against accidental algorithm changes', async () => { - // hex( HMAC-SHA256( key=Uint8Array(32).fill(1), msg='metamask:bip44:srp:fingerprint' ) ) - const fingerprint = await toEntropyFingerprint(SECRET, 'bip44:srp'); - expect(fingerprint).toBe( - '10d37bf4886303a200fe2e8147ded8e89458e37576a799ddaaacb33c89c07599', + // hex( HMAC-SHA256( key=Uint8Array(32).fill(1), msg='metamask:fingerprint' ) ) + const fp = await toEntropyFingerprint(SECRET); + expect(fp).toBe( + '29b2273601c75096dae59f735d55b5a24384e06ff17496130d059b1bd5915560', ); }); }); describe('toEntropyId', () => { - it('returns a valid UUID v4 string', async () => { - const id = await toEntropyId(SECRET, 'bip44:srp'); + it('returns the full entropy:category:implementation:uuid format', async () => { + const id = await toEntropyId('bip44', 'mnemonic', SECRET); expect(id).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + /^entropy:bip44:mnemonic:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, ); }); it('is deterministic — same inputs always produce the same ID', async () => { - const first = await toEntropyId(SECRET, 'bip44:srp'); - const second = await toEntropyId(SECRET, 'bip44:srp'); + const first = await toEntropyId('bip44', 'mnemonic', SECRET); + const second = await toEntropyId('bip44', 'mnemonic', SECRET); expect(first).toBe(second); }); - it('is type-scoped — different entropy types produce different IDs for the same secret', async () => { - const srpId = await toEntropyId(SECRET, 'bip44:srp'); - const pkId = await toEntropyId(SECRET, 'raw:private-key'); - expect(srpId).not.toBe(pkId); - }); - - it('produces different IDs for different secrets of the same type', async () => { + it('produces different IDs for different secrets', async () => { const otherSecret = new Uint8Array(32).fill(2); - const first = await toEntropyId(SECRET, 'bip44:srp'); - const second = await toEntropyId(otherSecret, 'bip44:srp'); + const first = await toEntropyId('bip44', 'mnemonic', SECRET); + const second = await toEntropyId('bip44', 'mnemonic', otherSecret); expect(first).not.toBe(second); }); + it('produces different IDs for different category:implementation combinations', async () => { + const mnemonicId = await toEntropyId('bip44', 'mnemonic', SECRET); + const privateKeyId = await toEntropyId('raw', 'private-key', SECRET); + expect(mnemonicId).not.toBe(privateKeyId); + }); + + it("uses '_' as the UUID segment when no material is provided (hardware wallets)", async () => { + const id = await toEntropyId('bip44', 'ledger'); + expect(id).toBe('entropy:bip44:ledger:_'); + }); + it('matches a known value to guard against accidental algorithm changes', async () => { - // uuid({ random: HMAC-SHA256( key=Uint8Array(32).fill(1), msg='metamask:bip44:srp:fingerprint' ).slice(0, 16) }) - const id = await toEntropyId(SECRET, 'bip44:srp'); - expect(id).toBe('10d37bf4-8863-43a2-80fe-2e8147ded8e8'); + const id = await toEntropyId('bip44', 'mnemonic', SECRET); + expect(id).toBe('entropy:bip44:mnemonic:29b22736-01c7-4096-9ae5-9f735d55b5a2'); }); }); diff --git a/packages/entropy-controller/src/utils.ts b/packages/entropy-controller/src/utils.ts index 32e439ec18c..b268e56e339 100644 --- a/packages/entropy-controller/src/utils.ts +++ b/packages/entropy-controller/src/utils.ts @@ -1,92 +1,65 @@ -import type { KeyringObject } from '@metamask/keyring-controller'; -import { KeyringTypes } from '@metamask/keyring-controller'; import { hmac } from '@noble/hashes/hmac'; import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex } from '@noble/hashes/utils'; import { v4 as uuid } from 'uuid'; -import type { EntropyId, EntropyType } from './types'; +import type { EntropyCategory, EntropyId, EntropyImplementation } from './types'; /** - * Checks whether a keyring is a source of entropy — i.e. whether it owns - * secret material that the {@link EntropyController} should track. + * Computes a deterministic, non-reversible fingerprint for a piece of entropy. * - * Currently matches HD keyrings (`'HD Key Tree'`, which own a BIP-39 mnemonic) - * and Simple keyrings (`'Simple Key Pair'`, which own imported private keys). - * - * @param keyring - The keyring object from `KeyringController` state. - * @returns `true` if the keyring owns entropy, `false` otherwise. - */ -export function isKeyringOwningEntropy(keyring: KeyringObject): boolean { - return keyring.type === KeyringTypes.hd || keyring.type === KeyringTypes.simple; -} - -/** - * Computes the raw HMAC-SHA256 digest for a piece of entropy — the shared - * primitive underlying both {@link toEntropyFingerprint} and - * {@link toEntropyId}. - * - * Using the secret as the HMAC key and the `EntropyType` as the domain - * separator ensures non-reversibility and type-scoping. + * The fingerprint is a UUID v4 seeded from the first 16 bytes of + * `HMAC-SHA256(key=material, msg='metamask:fingerprint')`. * * `@noble/hashes` is synchronous today, but the async signature is kept as a * forward-compatible seam: a future migration to the Web Crypto API (or any * other async primitive) won't require changes at every call site. * - * @param secret - The raw entropy bytes. - * @param entropyType - The type of entropy source. - * @returns The 32-byte HMAC-SHA256 digest. + * @param material - The raw entropy bytes (e.g. BIP-39 mnemonic bytes). + * @returns A deterministic UUID v4 string that uniquely identifies the entropy + * without exposing it. */ -async function toEntropyFingerprintBytes( - secret: Uint8Array, - entropyType: EntropyType, -): Promise { - const message = new TextEncoder().encode( - `metamask:${entropyType}:fingerprint`, - ); - return hmac(sha256, secret, message); +export async function fingerprint(material: Uint8Array): Promise { + const message = new TextEncoder().encode('metamask:fingerprint'); + const digest = hmac(sha256, material, message); + return uuid({ random: digest.slice(0, 16) }); } /** * Computes a deterministic, non-reversible hex fingerprint for a piece of - * entropy. - * - * The fingerprint is `hex(HMAC-SHA256(key=secret, msg='metamask:{entropyType}:fingerprint'))`. - * It is suitable for comparison and auditing — e.g. detecting that two - * separate components hold the same underlying secret. + * entropy. Suitable for comparison and auditing. * - * @param secret - The raw entropy bytes (e.g. BIP-39 mnemonic bytes, a 32-byte - * private key). - * @param entropyType - The type of entropy source, expressed as - * `category:implementation` (e.g. `'bip44:srp'`, `'raw:private-key'`). + * @param material - The raw entropy bytes. * @returns The lowercase hex-encoded 32-byte HMAC-SHA256 digest. */ export async function toEntropyFingerprint( - secret: Uint8Array, - entropyType: EntropyType, + material: Uint8Array, ): Promise { - return bytesToHex(await toEntropyFingerprintBytes(secret, entropyType)); + const message = new TextEncoder().encode('metamask:fingerprint'); + const digest = hmac(sha256, material, message); + return bytesToHex(digest); } /** - * Derives a deterministic UUID v4 to use as an {@link EntropyId}. + * Computes a stable {@link EntropyId} for an entropy source. * - * The UUID is built from the first 16 bytes of - * `HMAC-SHA256(key=secret, msg='metamask:{entropyType}:fingerprint')`, - * formatted as a standard UUID v4 (with the version and variant bits fixed by - * the UUID spec). Same secret + same type always produces the same ID. + * The ID is formatted as `entropy:{category}:{implementation}:{uuid}`, where + * the UUID segment is the {@link fingerprint} of `material` when provided, or + * `'_'` for entropy sources whose secret never leaves the device (e.g. hardware + * wallets). * - * @param secret - The raw entropy bytes (e.g. BIP-39 mnemonic bytes, a 32-byte - * private key). - * @param entropyType - The type of entropy source, expressed as - * `category:implementation` (e.g. `'bip44:srp'`, `'raw:private-key'`). - * @returns A deterministic UUID v4 string suitable for use as an - * {@link EntropyId}. + * @param category - The entropy category (e.g. `'bip44'`, `'raw'`). + * @param implementation - The entropy implementation (e.g. `'mnemonic'`, + * `'ledger'`, `'private-key'`). + * @param material - The raw entropy bytes. Omit for hardware wallets or any + * entropy source where the secret is not directly accessible. + * @returns A stable {@link EntropyId} string. */ export async function toEntropyId( - secret: Uint8Array, - entropyType: EntropyType, + category: EntropyCategory, + implementation: EntropyImplementation, + material?: Uint8Array, ): Promise { - const bytes = await toEntropyFingerprintBytes(secret, entropyType); - return uuid({ random: bytes.slice(0, 16) }); + const uuidSegment = material ? await fingerprint(material) : '_'; + return `entropy:${category}:${implementation}:${uuidSegment}`; } diff --git a/packages/keyring-controller/package.json b/packages/keyring-controller/package.json index a3cb2361892..b49db4b7fa2 100644 --- a/packages/keyring-controller/package.json +++ b/packages/keyring-controller/package.json @@ -59,6 +59,7 @@ "@metamask/base-controller": "^9.1.0", "@metamask/browser-passworder": "^6.0.0", "@metamask/controller-utils": "^12.3.0", + "@metamask/entropy-controller": "^0.0.0", "@metamask/eth-hd-keyring": "^14.1.1", "@metamask/eth-sig-util": "^8.2.0", "@metamask/eth-simple-keyring": "^12.0.2", diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index bc06f687427..a97e74b5245 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -6155,11 +6155,11 @@ function stubKeyringClassWithAccount( function buildRootMessenger(): RootMessenger { const messenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE }); messenger.registerActionHandler( - 'EntropyController:registerSource', - jest.fn().mockResolvedValue(undefined), + 'EntropyController:addEntropy', + jest.fn(), ); messenger.registerActionHandler( - 'EntropyController:unregisterSource', + 'EntropyController:removeEntropy', jest.fn(), ); return messenger; @@ -6185,8 +6185,8 @@ function buildKeyringControllerMessenger( messenger.delegate({ messenger: kcMessenger, actions: [ - 'EntropyController:registerSource', - 'EntropyController:unregisterSource', + 'EntropyController:addEntropy', + 'EntropyController:removeEntropy', ], }); diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index 45c58bf090d..de01ce59a48 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -15,10 +15,8 @@ import type { EthUserOperationPatch, KeyringAccount, } from '@metamask/keyring-api'; -import type { - Keyring as KeyringV2, - KeyringType, -} from '@metamask/keyring-api/v2'; +import { KeyringType } from '@metamask/keyring-api/v2'; +import type { Keyring as KeyringV2 } from '@metamask/keyring-api/v2'; import type { EthKeyring } from '@metamask/keyring-internal-api'; import type { Keyring, KeyringClass } from '@metamask/keyring-utils'; import type { Messenger } from '@metamask/messenger'; @@ -35,6 +33,13 @@ import { isValidJson, remove0x, } from '@metamask/utils'; +import { toEntropyId } from '@metamask/entropy-controller'; +import type { + Bip44MnemonicEntropy, + EntropyControllerAddEntropyAction, + EntropyControllerRemoveEntropyAction, + EntropyId, +} from '@metamask/entropy-controller'; import { Mutex } from 'async-mutex'; import type { MutexInterface } from 'async-mutex'; import Wallet, { thirdparty as importers } from 'ethereumjs-wallet'; @@ -196,34 +201,11 @@ export type KeyringControllerEvents = | KeyringControllerUnlockEvent | KeyringControllerAccountRemovedEvent; -// Inline types to avoid a circular package dependency with entropy-controller. -type EntropyControllerRegisterSourceAction = { - type: 'EntropyController:registerSource'; - handler: ( - source: - | { - type: 'bip44:srp'; - mnemonic: Uint8Array; - metadata: { legacyEntropySource: string }; - } - | { - type: 'raw:private-key'; - privateKey: Uint8Array; - metadata: { legacyEntropySource: string }; - }, - ) => Promise; -}; - -type EntropyControllerUnregisterSourceAction = { - type: 'EntropyController:unregisterSource'; - handler: (keyringId: string) => void; -}; - export type KeyringControllerMessenger = Messenger< typeof name, | KeyringControllerActions - | EntropyControllerRegisterSourceAction - | EntropyControllerUnregisterSourceAction, + | EntropyControllerAddEntropyAction + | EntropyControllerRemoveEntropyAction, KeyringControllerEvents >; @@ -791,6 +773,40 @@ function normalize(address: string): string | undefined { return isEthAddress(address) ? ethNormalize(address) : address; } +/** + * Returns `true` and narrows `keyringV2` to `HdKeyringV2 & { mnemonic: Uint8Array }` when + * the entry is a loaded HD keyring with accessible mnemonic bytes. + * + * @param keyring - The v1 keyring instance. + * @param keyringV2 - The optional v2 keyring instance. + * @returns `true` if the entry is an HD keyring v2 with an accessible mnemonic. + */ +function isHdKeyringV2( + keyring: EthKeyring, + keyringV2: KeyringV2 | undefined, +): keyringV2 is HdKeyringV2 & { mnemonic: Uint8Array } { + return ( + keyring.type === KeyringTypes.hd && + keyringV2?.type === KeyringType.Hd && + Boolean(keyringV2.mnemonic) + ); +} + +/** + * Computes the `EntropyId` for a BIP-44 mnemonic entropy source. + * + * Thin wrapper around {@link toEntropyId} that encodes the `'bip44'` category + * and `'mnemonic'` implementation so call sites don't repeat them. + * + * @param mnemonic - The raw mnemonic bytes. + * @returns The stable `EntropyId` for this mnemonic. + */ +async function toBip44MnemonicEntropyId( + mnemonic: Uint8Array, +): Promise { + return toEntropyId('bip44', 'mnemonic', mnemonic); +} + /** * Controller responsible for establishing and managing user identity. * @@ -1447,7 +1463,7 @@ export class KeyringController< async removeAccount(address: string): Promise { this.#assertIsUnlocked(); - let removedKeyringId: string | undefined; + let removedEntropy: EntropyId | undefined; await this.#persistOrRollback(async () => { const keyringIndex = await this.#findKeyringIndexForAccount(address); @@ -1487,15 +1503,17 @@ export class KeyringController< await keyring.removeAccount(address as Hex); if (shouldRemoveKeyring) { - removedKeyringId = this.#getKeyringMetadata(keyring).id; + if (isHdKeyringV2(keyring, keyringV2)) { + removedEntropy = await toBip44MnemonicEntropyId(keyringV2.mnemonic); + } this.#keyrings.splice(keyringIndex, 1); await this.#destroyKeyring(keyring, keyringV2); } }); this.messenger.publish(`${name}:accountRemoved`, address); - if (removedKeyringId) { - this.messenger.call('EntropyController:unregisterSource', removedKeyringId); + if (removedEntropy) { + this.messenger.call('EntropyController:removeEntropy', removedEntropy); } } @@ -2265,6 +2283,7 @@ export class KeyringController< const createdEntries = new Set(); const removedEntries = new Set(); + const removedEntropies: EntropyId[] = []; const result = await this.#persistOrRollback(async () => { @@ -2335,6 +2354,12 @@ export class KeyringController< throw error; } + for (const { keyring, keyringV2 } of removedEntries) { + if (isHdKeyringV2(keyring, keyringV2)) { + removedEntropies.push(await toBip44MnemonicEntropyId(keyringV2.mnemonic)); + } + } + await destroyKeyrings(removedEntries); // We update the real keyrings only after the operation completes successfully, so that @@ -2361,8 +2386,8 @@ export class KeyringController< for (const entry of createdEntries) { await this.#registerEntropySource(entry); } - for (const { metadata } of removedEntries) { - this.messenger.call('EntropyController:unregisterSource', metadata.id); + for (const entropyId of removedEntropies) { + this.messenger.call('EntropyController:removeEntropy', entropyId); } return result; @@ -2382,47 +2407,30 @@ export class KeyringController< } /** - * Registers entropy sources for a single keyring entry with - * `EntropyController`. Called after each successful persist operation. + * Registers an HD keyring's entropy source with `EntropyController`. * - * HD keyrings contribute one `bip44:srp` source (the mnemonic). - * Simple keyrings contribute one `raw:private-key` source per account, - * retrieved via `exportAccount` on the v2 keyring interface. - * - * The call is fire-and-forget from an error perspective — if `EntropyController` - * is not wired up in the current consumer, the messenger call throws and the - * error propagates. Consumers that do not use `EntropyController` should not - * wire `KeyringControllerMessenger` with these `AllowedActions`. + * Called after each successful persist. Non-HD keyrings are skipped. + * If `EntropyController` is not wired into the consumer's messenger, the + * call throws and the error propagates. * * @param entry - The keyring entry to register. */ async #registerEntropySource(entry: KeyringEntry): Promise { - const { keyring, keyringV2, metadata } = entry; - - if (keyring.type === KeyringTypes.hd && keyringV2) { - const hdKeyring = keyringV2 as HdKeyringV2; - if (hdKeyring.mnemonic) { - await this.messenger.call('EntropyController:registerSource', { - type: 'bip44:srp', - mnemonic: hdKeyring.mnemonic, - metadata: { legacyEntropySource: metadata.id }, - }); - } - } else if (keyring.type === KeyringTypes.simple && keyringV2) { - const simpleKeyring = keyringV2 as SimpleKeyringV2; - const accounts = await simpleKeyring.getAccounts(); - for (const account of accounts) { - const exported = await simpleKeyring.exportAccount(account.id, { - type: 'private-key', - encoding: 'hexadecimal', - }); - await this.messenger.call('EntropyController:registerSource', { - type: 'raw:private-key', - privateKey: hexToBytes(add0x(exported.privateKey)), - metadata: { legacyEntropySource: metadata.id }, - }); - } + const { keyring, keyringV2 } = entry; + + if (!isHdKeyringV2(keyring, keyringV2)) { + return; } + + const entropy: Bip44MnemonicEntropy = { + type: 'bip44:mnemonic', + id: await toBip44MnemonicEntropyId(keyringV2.mnemonic), + }; + + this.messenger.call('EntropyController:addEntropy', entropy); + + // TODO: set keyringV2.entropySource = entropy.id once @metamask/eth-hd-keyring/v2 + // exposes a setter or constructor option. } /** diff --git a/packages/keyring-controller/tsconfig.build.json b/packages/keyring-controller/tsconfig.build.json index df01f3c175b..55cbd978044 100644 --- a/packages/keyring-controller/tsconfig.build.json +++ b/packages/keyring-controller/tsconfig.build.json @@ -12,6 +12,9 @@ { "path": "../controller-utils/tsconfig.build.json" }, + { + "path": "../entropy-controller/tsconfig.build.json" + }, { "path": "../messenger/tsconfig.build.json" } diff --git a/packages/keyring-controller/tsconfig.json b/packages/keyring-controller/tsconfig.json index 7d7c67c579c..2d67095f9c4 100644 --- a/packages/keyring-controller/tsconfig.json +++ b/packages/keyring-controller/tsconfig.json @@ -10,6 +10,9 @@ { "path": "../controller-utils" }, + { + "path": "../entropy-controller" + }, { "path": "../messenger" } diff --git a/yarn.lock b/yarn.lock index 2318ab580e4..dbe98d31d31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7256,6 +7256,7 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/browser-passworder": "npm:^6.0.0" "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/entropy-controller": "npm:^0.0.0" "@metamask/eth-hd-keyring": "npm:^14.1.1" "@metamask/eth-sig-util": "npm:^8.2.0" "@metamask/eth-simple-keyring": "npm:^12.0.2" From 4cd51e64a5be6b9e92576b6ab663705083288a45 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 22 Jul 2026 18:35:36 +0200 Subject: [PATCH 18/19] refactor: rename to PascalCase --- .../{entropy-controller.test.ts => EntropyController.test.ts} | 4 ++-- .../src/{entropy-controller.ts => EntropyController.ts} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename packages/entropy-controller/src/{entropy-controller.test.ts => EntropyController.test.ts} (98%) rename packages/entropy-controller/src/{entropy-controller.ts => EntropyController.ts} (100%) diff --git a/packages/entropy-controller/src/entropy-controller.test.ts b/packages/entropy-controller/src/EntropyController.test.ts similarity index 98% rename from packages/entropy-controller/src/entropy-controller.test.ts rename to packages/entropy-controller/src/EntropyController.test.ts index bc6a1aa758d..5647a10f609 100644 --- a/packages/entropy-controller/src/entropy-controller.test.ts +++ b/packages/entropy-controller/src/EntropyController.test.ts @@ -6,8 +6,8 @@ import type { MessengerEvents, } from '@metamask/messenger'; -import type { EntropyControllerMessenger } from './entropy-controller'; -import { EntropyController } from './entropy-controller'; +import type { EntropyControllerMessenger } from './EntropyController'; +import { EntropyController } from './EntropyController'; import { toEntropyId } from './utils'; // Stable test secrets diff --git a/packages/entropy-controller/src/entropy-controller.ts b/packages/entropy-controller/src/EntropyController.ts similarity index 100% rename from packages/entropy-controller/src/entropy-controller.ts rename to packages/entropy-controller/src/EntropyController.ts From 4422efc5bec11914597982a68bcd9633ed0245f7 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 22 Jul 2026 18:38:37 +0200 Subject: [PATCH 19/19] refactor: use proper MESSENGER_EXPOSED_METHODS pattern --- .../EntropyController-method-action-types.ts | 34 +++++++++++++++++++ .../src/EntropyController.ts | 16 ++++----- 2 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 packages/entropy-controller/src/EntropyController-method-action-types.ts diff --git a/packages/entropy-controller/src/EntropyController-method-action-types.ts b/packages/entropy-controller/src/EntropyController-method-action-types.ts new file mode 100644 index 00000000000..d9fd88ead1c --- /dev/null +++ b/packages/entropy-controller/src/EntropyController-method-action-types.ts @@ -0,0 +1,34 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { EntropyController } from './EntropyController'; + +/** + * Registers an entropy source in the controller state. + * + * @param entropy - The entropy source to register, including its pre-computed + * ID and type. + */ +export type EntropyControllerAddEntropyAction = { + type: `EntropyController:addEntropy`; + handler: EntropyController['addEntropy']; +}; + +/** + * Removes an entropy source from the controller state. + * + * @param entropyId - The ID of the entropy source to remove. + */ +export type EntropyControllerRemoveEntropyAction = { + type: `EntropyController:removeEntropy`; + handler: EntropyController['removeEntropy']; +}; + +/** + * Union of all EntropyController action types. + */ +export type EntropyControllerMethodActions = + | EntropyControllerAddEntropyAction + | EntropyControllerRemoveEntropyAction; diff --git a/packages/entropy-controller/src/EntropyController.ts b/packages/entropy-controller/src/EntropyController.ts index b672b29b8c0..4380f9439b0 100644 --- a/packages/entropy-controller/src/EntropyController.ts +++ b/packages/entropy-controller/src/EntropyController.ts @@ -15,6 +15,11 @@ import type { Entropy, EntropyId, EntropyType } from './types'; */ const CONTROLLER_NAME = 'EntropyController'; +const MESSENGER_EXPOSED_METHODS = [ + 'addEntropy', + 'removeEntropy', +] as const; + /** * Describes the shape of the state object for {@link EntropyController}. */ @@ -159,14 +164,9 @@ export class EntropyController extends BaseController< }, }); - this.messenger.registerActionHandler( - `${CONTROLLER_NAME}:addEntropy`, - this.addEntropy.bind(this), - ); - - this.messenger.registerActionHandler( - `${CONTROLLER_NAME}:removeEntropy`, - this.removeEntropy.bind(this), + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, ); }