From 7849e31d1f93b3e71d7b601b9b29c27582f2deaa Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 14 May 2026 19:14:07 +0200 Subject: [PATCH 1/3] chore(keyring-controller): Mirror state from main onto rekm/wallet-cli rekm/wallet-cli is behind main on the keyring-controller package by two releases (25.4.0, 25.5.0). This brings the keyring-controller src, package.json, and CHANGELOG into byte-identical alignment with main (verified via `git diff main` returning empty for this directory) so the reconciliation disappears as a clean no-op when rekm/wallet-cli is eventually rebased onto main. This is a prerequisite for the companion wallet-cli changes in this PR: `KeyringController:submitPassword` (exposed via the messenger on main in #8674) is what the daemon auto-unlock and `mm wallet unlock` command dispatch through. Without this mirror the integration tests added in the next commit would fail with "A handler for KeyringController:submitPassword has not been delegated to Wallet". Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/keyring-controller/CHANGELOG.md | 53 ++++++-- packages/keyring-controller/package.json | 12 +- .../KeyringController-method-action-types.ts | 115 +++++++++++++++++- .../src/KeyringController.ts | 15 +++ packages/keyring-controller/src/index.ts | 9 ++ 5 files changed, 186 insertions(+), 18 deletions(-) diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index 41af242c98..7abb135677 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [25.5.0] + +### Added + +- Expose missing public `KeyringController` methods through its messenger ([#8674](https://github.com/MetaMask/core/pull/8674)) + - The following actions are now available: + - `KeyringController:changePassword`, + - `KeyringController:exportAccount`, + - `KeyringController:exportEncryptionKey`, + - `KeyringController:getAccountKeyringType`, + - `KeyringController:importAccountWithStrategy`, + - `KeyringController:setLocked`, + - `KeyringController:submitEncryptionKey`, + - `KeyringController:submitPassword`, + - `KeyringController:verifyPassword`, + - Corresponding action types are available as well. + +## [25.4.0] + +### Changed + +- Bump `@metamask/eth-hd-keyring` from `^14.1.0` to `^14.1.1` ([#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/eth-simple-keyring` from `^12.0.1` to `^12.0.2` ([#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/keyring-api` from `^23.0.1` to `^23.1.0` ([#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/keyring-internal-api` from `^11.0.0` to `^11.0.1` ([#8647](https://github.com/MetaMask/core/pull/8647)) + +## [25.3.0] + ### Added - Expose `KeyringController:exportSeedPhrase` method through `KeyringController` messenger ([#8587](https://github.com/MetaMask/core/pull/8587)) @@ -179,8 +207,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump `@metamask/keyring-api` from `^18.0.0` to `^20.0.0` ([#6146](https://github.com/MetaMask/core/pull/6146)), ([#6248](https://github.com/MetaMask/core/pull/6248)) -- Bump `@metamask/keyring-internal-api` from `^6.2.0` to `^8.0.0` ([#6146](https://github.com/MetaMask/core/pull/6146)), ([#6248](https://github.com/MetaMask/core/pull/6248)) +- Bump `@metamask/keyring-api` from `^18.0.0` to `^20.0.0` ([#6146](https://github.com/MetaMask/core/pull/6146), [#6248](https://github.com/MetaMask/core/pull/6248)) +- Bump `@metamask/keyring-internal-api` from `^6.2.0` to `^8.0.0` ([#6146](https://github.com/MetaMask/core/pull/6146), [#6248](https://github.com/MetaMask/core/pull/6248)) - Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) ## [22.1.0] @@ -304,7 +332,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/keyring-api"` from `^17.0.0` to `^17.2.0` ([#5366](https://github.com/MetaMask/core/pull/5366)) -- Bump `@metamask/keyring-internal-api` from `^4.0.1` to `^4.0.3` ([#5356](https://github.com/MetaMask/core/pull/5356)), ([#5366](https://github.com/MetaMask/core/pull/5366)) +- Bump `@metamask/keyring-internal-api` from `^4.0.1` to `^4.0.3` ([#5356](https://github.com/MetaMask/core/pull/5356), [#5366](https://github.com/MetaMask/core/pull/5366)) ### Fixed @@ -347,7 +375,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump `@metamask/keyring-api` from `^14.0.0` to `^16.1.0` ([#5190](https://github.com/MetaMask/core/pull/5190)), ([#5208](https://github.com/MetaMask/core/pull/5208)) +- Bump `@metamask/keyring-api` from `^14.0.0` to `^16.1.0` ([#5190](https://github.com/MetaMask/core/pull/5190), [#5208](https://github.com/MetaMask/core/pull/5208)) ## [19.0.4] @@ -361,9 +389,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.1` ([#5079](https://github.com/MetaMask/core/pull/5079)), ([#5135](https://github.com/MetaMask/core/pull/5135)) +- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.1`, ([#5079](https://github.com/MetaMask/core/pull/5079), [#5135](https://github.com/MetaMask/core/pull/5135)) - Bump `@metamask/keyring-api` from `^12.0.0` to `^13.0.0` ([#5066](https://github.com/MetaMask/core/pull/5066)) -- Bump `@metamask/keyring-internal-api` from `^1.0.0` to `^2.0.0` ([#5066](https://github.com/MetaMask/core/pull/5066)), ([#5136](https://github.com/MetaMask/core/pull/5136)) +- Bump `@metamask/keyring-internal-api` from `^1.0.0` to `^2.0.0` ([#5066](https://github.com/MetaMask/core/pull/5066), [#5136](https://github.com/MetaMask/core/pull/5136)) - Bump `@metamask/utils` to `^11.0.1` ([#5080](https://github.com/MetaMask/core/pull/5080)) - Bump `@metamask/rpc-errors` to `^7.0.2` ([#5080](https://github.com/MetaMask/core/pull/5080)) @@ -432,7 +460,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump accounts related packages ([#4713](https://github.com/MetaMask/core/pull/4713)), ([#4728](https://github.com/MetaMask/core/pull/4728)) +- Bump accounts related packages ([#4713](https://github.com/MetaMask/core/pull/4713), [#4728](https://github.com/MetaMask/core/pull/4728)) - Those packages are now built slightly differently and are part of the [accounts monorepo](https://github.com/MetaMask/accounts). - Bump `@metamask/keyring-api` from `^8.1.0` to `^8.1.4` - Bump `@metamask/eth-hd-keyring` from `^7.0.1` to `^7.0.4` @@ -450,7 +478,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). All of the ATTW checks now pass. -- Remove chunk files ([#4648](https://github.com/MetaMask/core/pull/4648)). +- Remove chunk files ([#4648](https://github.com/MetaMask/core/pull/4648)) - Previously, the build tool we used to generate JavaScript files extracted common code to "chunk" files. While this was intended to make this package more tree-shakeable, it also made debugging more difficult for our @@ -896,7 +924,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `cancelQRSynchronization` method ([#1387](https://github.com/MetaMask/core.git/pull/1387)) +- Add `cancelQRSynchronization` method ([#1387](https://github.com/MetaMask/core/pull/1387)) ## [5.0.0] @@ -950,7 +978,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:**: Bump eth-keyring-controller version to @metamask/eth-keyring-controller v10 ([#1072](https://github.com/MetaMask/core.git/pull/1072)) +- **BREAKING:**: Bump eth-keyring-controller version to @metamask/eth-keyring-controller v10 ([#1072](https://github.com/MetaMask/core/pull/1072)) - `exportSeedPhrase` now returns a `Uint8Array` typed SRP (can be converted to a string using [this approach](https://github.com/MetaMask/eth-hd-keyring/blob/53b0570559595ba5b3fd8c80e900d847cd6dee3d/index.js#L40)). It was previously a Buffer. - The HD keyring included with the keyring controller has been updated from v4 to v6. See [the `eth-hd-keyring` changelog entries for v5 and v6](https://github.com/MetaMask/eth-hd-keyring/blob/main/CHANGELOG.md#600) for further details on breaking changes. @@ -980,7 +1008,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@25.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@25.5.0...HEAD +[25.5.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@25.4.0...@metamask/keyring-controller@25.5.0 +[25.4.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@25.3.0...@metamask/keyring-controller@25.4.0 +[25.3.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@25.2.0...@metamask/keyring-controller@25.3.0 [25.2.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@25.1.1...@metamask/keyring-controller@25.2.0 [25.1.1]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@25.1.0...@metamask/keyring-controller@25.1.1 [25.1.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@25.0.0...@metamask/keyring-controller@25.1.0 diff --git a/packages/keyring-controller/package.json b/packages/keyring-controller/package.json index e6583cd429..f848b54e5a 100644 --- a/packages/keyring-controller/package.json +++ b/packages/keyring-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/keyring-controller", - "version": "25.2.0", + "version": "25.5.0", "description": "Stores identities seen in the wallet and manages interactions such as signing", "keywords": [ "Ethereum", @@ -56,11 +56,11 @@ "@ethereumjs/util": "^9.1.0", "@metamask/base-controller": "^9.1.0", "@metamask/browser-passworder": "^6.0.0", - "@metamask/eth-hd-keyring": "^14.1.0", + "@metamask/eth-hd-keyring": "^14.1.1", "@metamask/eth-sig-util": "^8.2.0", - "@metamask/eth-simple-keyring": "^12.0.1", - "@metamask/keyring-api": "^23.0.1", - "@metamask/keyring-internal-api": "^11.0.0", + "@metamask/eth-simple-keyring": "^12.0.2", + "@metamask/keyring-api": "^23.1.0", + "@metamask/keyring-internal-api": "^11.0.1", "@metamask/messenger": "^1.2.0", "@metamask/utils": "^11.9.0", "async-mutex": "^0.5.0", @@ -75,7 +75,7 @@ "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^6.1.0", - "@metamask/keyring-utils": "^3.1.0", + "@metamask/keyring-utils": "^3.2.1", "@metamask/scure-bip39": "^2.1.1", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", diff --git a/packages/keyring-controller/src/KeyringController-method-action-types.ts b/packages/keyring-controller/src/KeyringController-method-action-types.ts index 3c0a9ac6f7..1abd9cf97e 100644 --- a/packages/keyring-controller/src/KeyringController-method-action-types.ts +++ b/packages/keyring-controller/src/KeyringController-method-action-types.ts @@ -58,6 +58,17 @@ export type KeyringControllerAddNewKeyringAction = { handler: KeyringController['addNewKeyring']; }; +/** + * Method to verify a given password validity. Throws an + * error if the password is invalid. + * + * @param password - Password of the keyring. + */ +export type KeyringControllerVerifyPasswordAction = { + type: `KeyringController:verifyPassword`; + handler: KeyringController['verifyPassword']; +}; + /** * Returns the status of the vault. * @@ -80,6 +91,18 @@ export type KeyringControllerExportSeedPhraseAction = { handler: KeyringController['exportSeedPhrase']; }; +/** + * Gets the private key from the keyring controlling an address. + * + * @param password - Password of the keyring. + * @param address - Address to export. + * @returns Promise resolving to the private key for an address. + */ +export type KeyringControllerExportAccountAction = { + type: `KeyringController:exportAccount`; + handler: KeyringController['exportAccount']; +}; + /** * Returns the public addresses of all accounts from every keyring. * @@ -157,6 +180,19 @@ export type KeyringControllerPersistAllKeyringsAction = { handler: KeyringController['persistAllKeyrings']; }; +/** + * Imports an account with the specified import strategy. + * + * @param strategy - Import strategy name. + * @param args - Array of arguments to pass to the underlying stategy. + * @throws Will throw when passed an unrecognized strategy. + * @returns Promise resolving to the imported account address. + */ +export type KeyringControllerImportAccountWithStrategyAction = { + type: `KeyringController:importAccountWithStrategy`; + handler: KeyringController['importAccountWithStrategy']; +}; + /** * Removes an account from keyring state. * @@ -169,6 +205,16 @@ export type KeyringControllerRemoveAccountAction = { handler: KeyringController['removeAccount']; }; +/** + * Deallocates all secrets and locks the wallet. + * + * @returns Promise resolving when the operation completes. + */ +export type KeyringControllerSetLockedAction = { + type: `KeyringController:setLocked`; + handler: KeyringController['setLocked']; +}; + /** * Signs message by calling down into a specific keyring. * @@ -269,6 +315,53 @@ export type KeyringControllerSignUserOperationAction = { handler: KeyringController['signUserOperation']; }; +/** + * Changes the password used to encrypt the vault. + * + * @param password - The new password. + * @returns Promise resolving when the operation completes. + */ +export type KeyringControllerChangePasswordAction = { + type: `KeyringController:changePassword`; + handler: KeyringController['changePassword']; +}; + +/** + * Attempts to decrypt the current vault and load its keyrings, using the + * given encryption key and salt. The optional salt can be used to check for + * consistency with the vault salt. + * + * @param encryptionKey - Key to unlock the keychain. + * @param encryptionSalt - Optional salt to unlock the keychain. + * @returns Promise resolving when the operation completes. + */ +export type KeyringControllerSubmitEncryptionKeyAction = { + type: `KeyringController:submitEncryptionKey`; + handler: KeyringController['submitEncryptionKey']; +}; + +/** + * Exports the vault encryption key. + * + * @returns The vault encryption key. + */ +export type KeyringControllerExportEncryptionKeyAction = { + type: `KeyringController:exportEncryptionKey`; + handler: KeyringController['exportEncryptionKey']; +}; + +/** + * Attempts to decrypt the current vault and load its keyrings, + * using the given password. + * + * @param password - Password to unlock the keychain. + * @returns Promise resolving when the operation completes. + */ +export type KeyringControllerSubmitPasswordAction = { + type: `KeyringController:submitPassword`; + handler: KeyringController['submitPassword']; +}; + /** * Select a keyring and execute the given operation with * the selected keyring, as a mutually exclusive atomic @@ -415,6 +508,17 @@ export type KeyringControllerWithControllerAction = { handler: KeyringController['withController']; }; +/** + * Gets the type of the keyring that manages the specified account. + * + * @param account - The account address to look up. + * @returns A promise that resolves to the type of the keyring managing the account. + */ +export type KeyringControllerGetAccountKeyringTypeAction = { + type: `KeyringController:getAccountKeyringType`; + handler: KeyringController['getAccountKeyringType']; +}; + /** * Union of all KeyringController action types. */ @@ -423,15 +527,19 @@ export type KeyringControllerMethodActions = | KeyringControllerCreateNewVaultAndRestoreAction | KeyringControllerCreateNewVaultAndKeychainAction | KeyringControllerAddNewKeyringAction + | KeyringControllerVerifyPasswordAction | KeyringControllerIsUnlockedAction | KeyringControllerExportSeedPhraseAction + | KeyringControllerExportAccountAction | KeyringControllerGetAccountsAction | KeyringControllerGetEncryptionPublicKeyAction | KeyringControllerDecryptMessageAction | KeyringControllerGetKeyringForAccountAction | KeyringControllerGetKeyringsByTypeAction | KeyringControllerPersistAllKeyringsAction + | KeyringControllerImportAccountWithStrategyAction | KeyringControllerRemoveAccountAction + | KeyringControllerSetLockedAction | KeyringControllerSignMessageAction | KeyringControllerSignEip7702AuthorizationAction | KeyringControllerSignPersonalMessageAction @@ -440,8 +548,13 @@ export type KeyringControllerMethodActions = | KeyringControllerPrepareUserOperationAction | KeyringControllerPatchUserOperationAction | KeyringControllerSignUserOperationAction + | KeyringControllerChangePasswordAction + | KeyringControllerSubmitEncryptionKeyAction + | KeyringControllerExportEncryptionKeyAction + | KeyringControllerSubmitPasswordAction | KeyringControllerWithKeyringAction | KeyringControllerWithKeyringUnsafeAction | KeyringControllerWithKeyringV2Action | KeyringControllerWithKeyringV2UnsafeAction - | KeyringControllerWithControllerAction; + | KeyringControllerWithControllerAction + | KeyringControllerGetAccountKeyringTypeAction; diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index ba5ea40a67..c95ca9f2d0 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -78,6 +78,15 @@ const MESSENGER_EXPOSED_METHODS = [ 'removeAccount', 'isUnlocked', 'exportSeedPhrase', + 'changePassword', + 'exportAccount', + 'exportEncryptionKey', + 'getAccountKeyringType', + 'importAccountWithStrategy', + 'setLocked', + 'submitEncryptionKey', + 'submitPassword', + 'verifyPassword', ] as const; /** @@ -2207,6 +2216,12 @@ export class KeyringController< }); } + /** + * Gets the type of the keyring that manages the specified account. + * + * @param account - The account address to look up. + * @returns A promise that resolves to the type of the keyring managing the account. + */ async getAccountKeyringType(account: string): Promise { this.#assertIsUnlocked(); diff --git a/packages/keyring-controller/src/index.ts b/packages/keyring-controller/src/index.ts index 484bdf3a5c..4c90ec9703 100644 --- a/packages/keyring-controller/src/index.ts +++ b/packages/keyring-controller/src/index.ts @@ -26,6 +26,15 @@ export type { KeyringControllerWithKeyringV2Action, KeyringControllerWithKeyringV2UnsafeAction, KeyringControllerExportSeedPhraseAction, + KeyringControllerVerifyPasswordAction, + KeyringControllerExportAccountAction, + KeyringControllerImportAccountWithStrategyAction, + KeyringControllerSetLockedAction, + KeyringControllerChangePasswordAction, + KeyringControllerSubmitEncryptionKeyAction, + KeyringControllerExportEncryptionKeyAction, + KeyringControllerSubmitPasswordAction, + KeyringControllerGetAccountKeyringTypeAction, } from './KeyringController-method-action-types'; export type * from './types'; export * from './errors'; From 41bdcc03d2750f986edc2021a3e26fa5d25b0505 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 14 May 2026 19:14:48 +0200 Subject: [PATCH 2/3] fix(wallet-cli): Auto-unlock keyring on subsequent daemon starts (#8776) Today the daemon imports the SRP on first run (which leaves the keyring unlocked) but on subsequent runs only hydrates state from `/wallet.db` and constructs the Wallet. The persisted KeyringController vault is reused, but `KeyringController.isUnlocked` is `persist: false` and defaults back to `false`, so any messenger action touching keyring-bound state (signing, `AccountsController:listAccounts`, etc.) failed after a daemon restart even though the password was already supplied via `--password` / `MM_WALLET_PASSWORD`. Changes: - `wallet-factory.ts` now calls `KeyringController:submitPassword` on the subsequent-run branch when a password is supplied, unlocking the keyring before returning. Wrong-password rejections surface as the rejection from `submitPassword`; the existing `catch` destroys the partial wallet and closes the store. First-run + no-password is rejected with a clear error *before* the Wallet is constructed, so a doomed startup doesn't build then tear down a Wallet. - `--password` / `MM_WALLET_PASSWORD` are now optional on `mm daemon start`. On subsequent runs, omitting them leaves the keyring locked; the companion `mm wallet unlock` command (next commit) is the affordance to unlock later. First-run still requires a password (enforced by `wallet-factory.ts`). - Empty-string password is normalised to `undefined` at the wallet-factory boundary so `--password ''` is treated as "no password supplied" rather than "the empty string is the password" (which the controller would reject as wrong). - `daemon-spawn.ts` only forwards `MM_WALLET_PASSWORD` to the child env when the caller explicitly supplied one; it deletes the variable from the spread otherwise so a stray inherited value doesn't override an explicit omission. Tests: - Unit tests added in `wallet-factory.test.ts` covering all four branches (first-run +/- password, subsequent +/- password), the empty-string normalisation, and the wrong-password cleanup path. - New `wallet-factory.integration.test.ts` (no mocks of `@metamask/wallet`) covers the full first-run -> destroy -> restart lifecycle, the auto-unlock path, the locked-then-unlock-via- submitPassword path, and that a wrong password rejects without corrupting the DB (a retry with the right password succeeds). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/wallet-cli/CHANGELOG.md | 2 +- .../wallet-cli/src/commands/daemon/start.ts | 6 +- .../src/daemon/daemon-entry.test.ts | 10 +- .../wallet-cli/src/daemon/daemon-entry.ts | 8 +- .../src/daemon/daemon-spawn.test.ts | 37 ++++ .../wallet-cli/src/daemon/daemon-spawn.ts | 26 ++- packages/wallet-cli/src/daemon/types.ts | 8 +- .../daemon/wallet-factory.integration.test.ts | 188 ++++++++++++++++++ .../src/daemon/wallet-factory.test.ts | 111 ++++++++++- .../wallet-cli/src/daemon/wallet-factory.ts | 53 ++++- 10 files changed, 421 insertions(+), 28 deletions(-) create mode 100644 packages/wallet-cli/src/daemon/wallet-factory.integration.test.ts diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index 1f4ed792e4..1e7630ff7b 100644 --- a/packages/wallet-cli/CHANGELOG.md +++ b/packages/wallet-cli/CHANGELOG.md @@ -13,6 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `mm daemon start` spawns the daemon with `--infura-project-id`, `--password`, `--srp` (or the matching env vars). - `mm daemon call []` dispatches any messenger action over JSON-RPC. - `mm daemon stop`, `mm daemon status`, `mm daemon purge` manage daemon lifecycle and state. -- Persist daemon state to a SQLite database at `/wallet.db`; subsequent `daemon start` runs reuse the persisted KeyringController vault instead of re-importing the SRP ([#8446](https://github.com/MetaMask/core/pull/8446)). +- Persist daemon state to a SQLite database at `/wallet.db`; subsequent `daemon start` runs reuse the persisted KeyringController vault instead of re-importing the SRP, and the daemon auto-unlocks the keyring with the supplied password so keyring-bound messenger actions (signing, `AccountsController:listAccounts`, etc.) work immediately after a restart. `--password` is optional on subsequent runs ([#8446](https://github.com/MetaMask/core/pull/8446)). [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/wallet-cli/src/commands/daemon/start.ts b/packages/wallet-cli/src/commands/daemon/start.ts index fb14e29f4d..2f288befc5 100644 --- a/packages/wallet-cli/src/commands/daemon/start.ts +++ b/packages/wallet-cli/src/commands/daemon/start.ts @@ -8,6 +8,7 @@ export default class DaemonStart extends Command { static override examples = [ '<%= config.bin %> daemon start --infura-project-id --password --srp ', 'INFURA_PROJECT_ID= MM_WALLET_PASSWORD= MM_WALLET_SRP= <%= config.bin %> daemon start', + '<%= config.bin %> daemon start --infura-project-id --srp # then `mm wallet unlock` later', ]; static override flags = { @@ -18,9 +19,10 @@ export default class DaemonStart extends Command { }), password: Flags.string({ description: - 'Wallet password (testing only — use MM_WALLET_PASSWORD env var in production)', + 'Wallet password (testing only — use MM_WALLET_PASSWORD env var in production). ' + + 'Required on first run; on subsequent runs, omit to start with a locked keyring and use `mm wallet unlock`.', env: 'MM_WALLET_PASSWORD', - required: true, + required: false, }), srp: Flags.string({ description: diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index 6b81d63da2..d33ae0f5c3 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -153,15 +153,17 @@ describe('daemon-entry', () => { expect(process.exitCode).toBe(1); }); - it('writes to stderr and sets exitCode when MM_WALLET_PASSWORD is missing', async () => { + it('passes password: undefined to createWallet when MM_WALLET_PASSWORD is absent', async () => { delete process.env.MM_WALLET_PASSWORD; + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); await importDaemonEntry(); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('MM_WALLET_PASSWORD'), + expect(mockCreateWallet).toHaveBeenCalledWith( + expect.objectContaining({ password: undefined }), ); - expect(process.exitCode).toBe(1); + expect(process.exitCode).toBeUndefined(); }); it('writes to stderr and sets exitCode when MM_WALLET_SRP is missing', async () => { diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index 8a27f7df40..d989e9c788 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -33,10 +33,12 @@ async function main(): Promise { throw new Error('INFURA_PROJECT_ID environment variable is required'); } + // Password is optional: when absent, the daemon starts without unlocking + // the keyring (e.g. when the user prefers to call `mm wallet unlock` + // interactively rather than embed the password in their environment). + // First-run startup still requires a password; wallet-factory enforces + // that and surfaces a clear error. const password = process.env.MM_WALLET_PASSWORD; - if (!password) { - throw new Error('MM_WALLET_PASSWORD environment variable is required'); - } const srp = process.env.MM_WALLET_SRP; if (!srp) { diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts index 4828f4d33b..8139f05876 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts @@ -256,6 +256,43 @@ describe('ensureDaemon', () => { expect(spawnMock.on).toHaveBeenCalledWith('exit', expect.any(Function)); }); + it('omits MM_WALLET_PASSWORD from the child env when no password is supplied', async () => { + mockPingDaemon + .mockResolvedValueOnce(ABSENT) + .mockResolvedValueOnce(RESPONSIVE); + mockExistsSync.mockReturnValue(true); + + // Snapshot+restore the whole env via assignment so the await between + // mutation and restore does not trip `require-atomic-updates`. + const savedEnv = process.env; + process.env = { ...savedEnv, MM_WALLET_PASSWORD: 'leaked-from-parent' }; + let spawnedEnv: NodeJS.ProcessEnv | undefined; + try { + const { password: _password, ...configWithoutPassword } = CONFIG; + await ensureDaemon(configWithoutPassword); + spawnedEnv = (mockSpawn.mock.calls[0][2] as { env: NodeJS.ProcessEnv }) + .env; + } finally { + // Restoring after await is intentional; jest runs each test serially. + // eslint-disable-next-line require-atomic-updates + process.env = savedEnv; + } + + expect(spawnedEnv).not.toHaveProperty('MM_WALLET_PASSWORD'); + }); + + it('forwards an explicitly-supplied password to the child env', async () => { + mockPingDaemon + .mockResolvedValueOnce(ABSENT) + .mockResolvedValueOnce(RESPONSIVE); + mockExistsSync.mockReturnValue(true); + + await ensureDaemon({ ...CONFIG, password: 'explicit-pass' }); + + const spawnOpts = mockSpawn.mock.calls[0][2] as { env: NodeJS.ProcessEnv }; + expect(spawnOpts.env.MM_WALLET_PASSWORD).toBe('explicit-pass'); + }); + it('writes spawn errors to stderr', async () => { mockPingDaemon .mockResolvedValueOnce(ABSENT) diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.ts b/packages/wallet-cli/src/daemon/daemon-spawn.ts index 834ed8b267..7fe6e480b5 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.ts @@ -63,17 +63,27 @@ export async function ensureDaemon( const { entryPath, args } = resolveEntryPoint(config.packageRoot); + const childEnv: NodeJS.ProcessEnv = { + ...process.env, + MM_DAEMON_DATA_DIR: config.dataDir, + MM_DAEMON_SOCKET_PATH: socketPath, + INFURA_PROJECT_ID: config.infuraProjectId, + MM_WALLET_SRP: config.srp, + }; + // Strip any inherited `MM_WALLET_PASSWORD` from the child env when the + // caller did not pass a password: the daemon treats an absent variable as + // "start locked", but assigning `undefined` via `env` would set the literal + // string `'undefined'` and be interpreted as a (wrong) password. + if (config.password === undefined) { + delete childEnv.MM_WALLET_PASSWORD; + } else { + childEnv.MM_WALLET_PASSWORD = config.password; + } + const child = spawn(process.execPath, [...args, entryPath], { detached: true, stdio: 'ignore', - env: { - ...process.env, - MM_DAEMON_DATA_DIR: config.dataDir, - MM_DAEMON_SOCKET_PATH: socketPath, - INFURA_PROJECT_ID: config.infuraProjectId, - MM_WALLET_PASSWORD: config.password, - MM_WALLET_SRP: config.srp, - }, + env: childEnv, }); type ExitInfo = { code: number | null; signal: NodeJS.Signals | null }; diff --git a/packages/wallet-cli/src/daemon/types.ts b/packages/wallet-cli/src/daemon/types.ts index 8e9e8be50e..dfd217ad1a 100644 --- a/packages/wallet-cli/src/daemon/types.ts +++ b/packages/wallet-cli/src/daemon/types.ts @@ -32,11 +32,17 @@ export type DaemonStatusInfo = { /** * Configuration passed to the daemon spawner. + * + * `password` is optional: when omitted, the daemon starts without unlocking + * the keyring, and the caller is expected to use `mm wallet unlock` before + * any keyring-bound operation. First-run startup still requires both + * `password` and `srp`; without `password`, the daemon will exit during + * startup with a clear error. */ export type DaemonSpawnConfig = { dataDir: string; infuraProjectId: string; - password: string; + password?: string; srp: string; packageRoot: string; }; diff --git a/packages/wallet-cli/src/daemon/wallet-factory.integration.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.integration.test.ts new file mode 100644 index 0000000000..064477a5e6 --- /dev/null +++ b/packages/wallet-cli/src/daemon/wallet-factory.integration.test.ts @@ -0,0 +1,188 @@ +import { rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createWallet } from './wallet-factory'; + +/** + * Real-Wallet integration tests for `createWallet`. Every other suite in this + * package mocks `@metamask/wallet` and `@metamask/remote-feature-flag-controller`; + * these tests intentionally do not, so they exercise the actual + * KeyringController encryption + persistence flow. They are slower than the + * unit tests (real KDF + SQLite I/O) but guard against bugs that only show + * up across the full lifecycle: that the persisted vault really survives a + * restart, that `KeyringController:submitPassword` is the correct messenger + * action to unlock it, and that a wrong password is surfaced cleanly without + * leaving the daemon wedged. + */ + +const TEST_PHRASE = + 'test test test test test test test test test test test ball'; +const TEST_PASSWORD = 'integration-pass'; +const INFURA_PROJECT_ID = 'fake-infura-project-id'; + +// SRP import runs a real KDF and SQLite writes; the default 5s jest timeout +// is occasionally tight on slower CI hardware. +jest.setTimeout(30_000); + +const createdTempDbPaths: string[] = []; + +/** + * Build a unique on-disk SQLite path under the OS temp dir and remember it + * for `afterEach` cleanup. Includes a random suffix so concurrent test runs + * cannot collide. + * + * @param label - A short label that makes the resulting filename traceable. + * @returns An absolute file path inside `os.tmpdir()`. + */ +function tempDbPath(label: string): string { + const path = join( + tmpdir(), + `wallet-cli-it-${label}-${process.pid}-${Date.now()}-${Math.random()}.db`, + ); + createdTempDbPaths.push(path); + return path; +} + +describe('createWallet (integration)', () => { + afterEach(() => { + while (createdTempDbPaths.length > 0) { + const path = createdTempDbPaths.pop() as string; + for (const candidate of [path, `${path}-wal`, `${path}-shm`]) { + rmSync(candidate, { force: true }); + } + } + }); + + it('imports the SRP on first run and lists accounts via the messenger', async () => { + const databasePath = tempDbPath('first-run'); + + const { wallet, store } = await createWallet({ + databasePath, + infuraProjectId: INFURA_PROJECT_ID, + password: TEST_PASSWORD, + srp: TEST_PHRASE, + }); + + try { + expect(wallet.state.KeyringController.isUnlocked).toBe(true); + const accounts = wallet.messenger.call('AccountsController:listAccounts'); + expect(accounts).toHaveLength(1); + } finally { + await wallet.destroy(); + store.close(); + } + }); + + it('auto-unlocks on a subsequent run when the password is supplied', async () => { + const databasePath = tempDbPath('subsequent-unlock'); + + const first = await createWallet({ + databasePath, + infuraProjectId: INFURA_PROJECT_ID, + password: TEST_PASSWORD, + srp: TEST_PHRASE, + }); + const firstAddress = first.wallet.messenger + .call('AccountsController:listAccounts') + .map((account) => account.address)[0]; + await first.wallet.destroy(); + first.store.close(); + + const second = await createWallet({ + databasePath, + infuraProjectId: INFURA_PROJECT_ID, + password: TEST_PASSWORD, + srp: TEST_PHRASE, + }); + + try { + expect(second.wallet.state.KeyringController.isUnlocked).toBe(true); + const accounts = second.wallet.messenger.call( + 'AccountsController:listAccounts', + ); + expect(accounts.map((account) => account.address)).toStrictEqual([ + firstAddress, + ]); + } finally { + await second.wallet.destroy(); + second.store.close(); + } + }); + + it('starts a subsequent run locked when no password is supplied, then unlocks via submitPassword', async () => { + const databasePath = tempDbPath('subsequent-no-password'); + + const first = await createWallet({ + databasePath, + infuraProjectId: INFURA_PROJECT_ID, + password: TEST_PASSWORD, + srp: TEST_PHRASE, + }); + await first.wallet.destroy(); + first.store.close(); + + const second = await createWallet({ + databasePath, + infuraProjectId: INFURA_PROJECT_ID, + srp: TEST_PHRASE, + }); + + try { + expect(second.wallet.state.KeyringController.isUnlocked).toBe(false); + + await second.wallet.messenger.call( + 'KeyringController:submitPassword', + TEST_PASSWORD, + ); + + expect(second.wallet.state.KeyringController.isUnlocked).toBe(true); + expect( + second.wallet.messenger.call('AccountsController:listAccounts'), + ).toHaveLength(1); + } finally { + await second.wallet.destroy(); + second.store.close(); + } + }); + + it('rejects subsequent-run startup with a wrong password and leaves the DB usable for a retry', async () => { + const databasePath = tempDbPath('wrong-password'); + + const first = await createWallet({ + databasePath, + infuraProjectId: INFURA_PROJECT_ID, + password: TEST_PASSWORD, + srp: TEST_PHRASE, + }); + await first.wallet.destroy(); + first.store.close(); + + await expect( + createWallet({ + databasePath, + infuraProjectId: INFURA_PROJECT_ID, + password: 'definitely-not-the-right-password', + srp: TEST_PHRASE, + }), + ).rejects.toThrow(/incorrect password|decrypt/iu); + + // The DB must be untouched: a retry with the real password still works. + const retry = await createWallet({ + databasePath, + infuraProjectId: INFURA_PROJECT_ID, + password: TEST_PASSWORD, + srp: TEST_PHRASE, + }); + + try { + expect(retry.wallet.state.KeyringController.isUnlocked).toBe(true); + expect( + retry.wallet.messenger.call('AccountsController:listAccounts'), + ).toHaveLength(1); + } finally { + await retry.wallet.destroy(); + retry.store.close(); + } + }); +}); diff --git a/packages/wallet-cli/src/daemon/wallet-factory.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.test.ts index f6f0269ade..37ca87ace6 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.test.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.test.ts @@ -187,7 +187,7 @@ describe('createWallet', () => { store.close(); }); - it('skips importing the SRP when the store already contains a KeyringController vault', async () => { + it('skips importing the SRP and unlocks the persisted vault on subsequent runs', async () => { jest.spyOn(persistenceModule, 'loadState').mockReturnValue({ KeyringController: { vault: 'encrypted-vault-blob' }, }); @@ -195,10 +195,119 @@ describe('createWallet', () => { const { store } = await createWallet(CONFIG); expect(mockImportSrp).not.toHaveBeenCalled(); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:submitPassword', + 'test-pass', + ); + + store.close(); + }); + + it('does not call submitPassword on first run', async () => { + await createWallet(CONFIG); + + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'KeyringController:submitPassword', + expect.anything(), + ); + }); + + it('throws a clear error when first-run startup has no password', async () => { + const { password: _password, ...configWithoutPassword } = CONFIG; + + await expect(createWallet(configWithoutPassword)).rejects.toThrow( + /password is required on first run/iu, + ); + + expect(mockImportSrp).not.toHaveBeenCalled(); + }); + + it('treats an empty-string password as no password (subsequent run stays locked)', async () => { + // Empty `--password ''` or empty `MM_WALLET_PASSWORD` env var means + // "no password supplied", not "the empty string is the password"; + // otherwise the daemon would try `submitPassword('')` and fail with a + // wrong-password error instead of the intended "start locked" path. + jest.spyOn(persistenceModule, 'loadState').mockReturnValue({ + KeyringController: { vault: 'encrypted-vault-blob' }, + }); + + const { store } = await createWallet({ ...CONFIG, password: '' }); + + expect(mockImportSrp).not.toHaveBeenCalled(); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'KeyringController:submitPassword', + expect.anything(), + ); + + store.close(); + }); + + it('rejects empty-string password on first run with the same error as missing password', async () => { + await expect(createWallet({ ...CONFIG, password: '' })).rejects.toThrow( + /password is required on first run/iu, + ); + + expect(mockImportSrp).not.toHaveBeenCalled(); + }); + + it('starts subsequent runs with a locked keyring when no password is supplied', async () => { + jest.spyOn(persistenceModule, 'loadState').mockReturnValue({ + KeyringController: { vault: 'encrypted-vault-blob' }, + }); + const { password: _password, ...configWithoutPassword } = CONFIG; + + const { store } = await createWallet(configWithoutPassword); + + expect(mockImportSrp).not.toHaveBeenCalled(); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'KeyringController:submitPassword', + expect.anything(), + ); store.close(); }); + it('destroys the wallet and rethrows when submitPassword rejects on a subsequent run', async () => { + jest.spyOn(persistenceModule, 'loadState').mockReturnValue({ + KeyringController: { vault: 'encrypted-vault-blob' }, + }); + const failure = new Error('wrong password'); + mockMessenger.call.mockImplementation((action: string) => { + if (action === 'KeyringController:submitPassword') { + return Promise.reject(failure); + } + return undefined; + }); + const closeSpy = jest.spyOn(KeyValueStore.prototype, 'close'); + + await expect(createWallet(CONFIG)).rejects.toThrow(failure); + + const constructedWallet = MockWallet.mock.results[0]?.value as Wallet; + expect(constructedWallet.destroy).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('does not remove the database when submitPassword rejects on a subsequent run', async () => { + jest.spyOn(persistenceModule, 'loadState').mockReturnValue({ + KeyringController: { vault: 'encrypted-vault-blob' }, + }); + mockMessenger.call.mockImplementation((action: string) => { + if (action === 'KeyringController:submitPassword') { + return Promise.reject(new Error('wrong password')); + } + return undefined; + }); + + await expect( + createWallet({ + ...CONFIG, + databasePath: tempDbPath('subsequent-unlock-failure'), + }), + ).rejects.toThrow('wrong password'); + + expect(mockRm).not.toHaveBeenCalled(); + }); + it('closes the store and rethrows when state hydration fails', async () => { const failure = new Error('corrupt store'); jest.spyOn(persistenceModule, 'loadState').mockImplementation(() => { diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts index 08a95f9833..349c6f2d81 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -26,10 +26,15 @@ export type CreateWalletResult = { * so all persist-flagged properties are written through. * * If the store does not yet contain a keyring vault (first-run), the supplied - * secret recovery phrase is imported. On subsequent runs, the persisted vault - * is reused and both `password` and `srp` are unused by this function; the - * wallet still starts locked and the caller is responsible for unlocking it - * via `KeyringController:submitPassword` before any keyring-bound operation. + * secret recovery phrase is imported using the supplied password. On + * subsequent runs, the persisted vault is reused: when a password is + * supplied, the wallet is unlocked via `KeyringController:submitPassword` so + * keyring-bound messenger actions work immediately; when no password is + * supplied, the wallet starts locked and the caller is expected to invoke + * `mm wallet unlock` before any keyring-bound operation. First-run startup + * without a password is rejected (the SRP cannot be imported without one). + * On a subsequent run, a wrong password surfaces as the rejection thrown by + * `submitPassword`. * * On any failure after the wallet is constructed, the wallet is destroyed * before the store is closed so persistence handlers unsubscribe cleanly. On a @@ -40,8 +45,11 @@ export type CreateWalletResult = { * @param config.databasePath - The path to the SQLite database file (or * `':memory:'` for ephemeral use). * @param config.infuraProjectId - The Infura project ID for network access. - * @param config.password - The wallet password. - * @param config.srp - The secret recovery phrase (BIP-39 mnemonic). + * @param config.password - The wallet password. Optional on subsequent runs; + * when omitted, the daemon starts with a locked keyring. Required on first + * run (to import the SRP). + * @param config.srp - The secret recovery phrase (BIP-39 mnemonic). Used + * only on first run. * @param config.log - Optional logger for persistence-write failures. * @returns The Wallet instance and the underlying KeyValueStore. The caller * owns the store and must close it after destroying the wallet (closing @@ -56,7 +64,7 @@ export async function createWallet({ }: { databasePath: string; infuraProjectId: string; - password: string; + password?: string; srp: string; /** * Optional logger for persistence-write failures. Without it, failures @@ -65,6 +73,13 @@ export async function createWallet({ */ log?: (message: string) => void; }): Promise { + // An empty `--password` flag or `MM_WALLET_PASSWORD` env var means "no + // password supplied", not "the empty string is my password". Collapsing + // the ambiguity here avoids the daemon trying to submit `''` to the + // keyring (which would surface as a wrong-password error rather than the + // intended "start locked" behavior). + const effectivePassword = password === '' ? undefined : password; + const store = new KeyValueStore(databasePath); let wallet: Wallet | undefined; let wasFirstRun = false; @@ -73,6 +88,16 @@ export async function createWallet({ const state = loadState(store); wasFirstRun = !hasPersistedKeyring(state); + // Validate the first-run precondition BEFORE constructing the wallet, + // so a doomed startup doesn't build a Wallet (and wire persistence + // handlers) just to tear it down. + if (wasFirstRun && effectivePassword === undefined) { + throw new Error( + 'A password is required on first run to import the secret recovery phrase. ' + + 'Pass `--password` (or `MM_WALLET_PASSWORD`) on `mm daemon start`.', + ); + } + wallet = new Wallet({ state, infuraProjectId, @@ -93,7 +118,19 @@ export async function createWallet({ subscribeToChanges(wallet.messenger, wallet.controllerMetadata, store, log); if (wasFirstRun) { - await importSecretRecoveryPhrase(wallet, password, srp); + // The precondition check above narrows `effectivePassword` to a + // defined string on this branch; TS can't follow that, hence the + // non-null assertion. + await importSecretRecoveryPhrase( + wallet, + effectivePassword as string, + srp, + ); + } else if (effectivePassword !== undefined) { + await wallet.messenger.call( + 'KeyringController:submitPassword', + effectivePassword, + ); } return { wallet, store }; From 9e384d1e5f8fa81e617808b13cbf24ba4654afcb Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 14 May 2026 19:15:17 +0200 Subject: [PATCH 3/3] feat(wallet-cli): Add 'mm wallet unlock' command (#8780) Companion to the auto-unlock fix in the previous commit. With `--password` now optional on `mm daemon start`, a daemon can be started with the keyring locked; this command is the affordance to unlock it later. Also covers the general case of a daemon that was unlocked, then locked via `mm daemon call KeyringController:setLocked`. `packages/wallet-cli/src/commands/wallet/unlock.ts` Dispatches `KeyringController:submitPassword` over the daemon socket via `sendCommand`. `--password` flag is optional with `MM_WALLET_PASSWORD` env fallback; when neither is supplied, the command prompts interactively via the new `promptPassword` helper (masked input). Empty `--password ''` is treated as "no flag supplied" so the prompt fires instead of sending an empty string the controller would reject. Errors surface with friendly messages: ENOENT/ECONNREFUSED -> "Daemon is not running" hint; EACCES -> permission-denied hint with an `MM_DAEMON_DATA_DIR` pointer; JSON-RPC failures -> "Failed to unlock: (code ) data=". `packages/wallet-cli/src/daemon/prompts.ts` New `promptPassword` helper using the same dynamic-import + ESM-interop pattern as the existing `confirmPurge`. Adds `@inquirer/password@^4.0.16` as a dependency. Placement under the new `wallet` oclif topic (rather than `daemon`) because unlocking is a wallet/keyring-lifecycle operation, not a daemon-lifecycle one. oclif auto-registers the topic from the directory structure; no extra config needed. Tests (14 cases) cover: happy path, env-var fallback, interactive prompt when neither flag nor env is supplied, prompt when `--password ''` is supplied, ENOENT/ECONNREFUSED/EACCES/other socket errors, JSON-RPC failure rendering (including the `data` field), non-Error throws, timeout flag, idempotent re-unlock. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/wallet-cli/CHANGELOG.md | 3 +- packages/wallet-cli/package.json | 1 + .../src/commands/wallet/unlock.test.ts | 206 ++++++++++++++++++ .../wallet-cli/src/commands/wallet/unlock.ts | 90 ++++++++ .../wallet-cli/src/daemon/prompts.test.ts | 32 ++- packages/wallet-cli/src/daemon/prompts.ts | 16 ++ yarn.lock | 183 +++++++++++++++- 7 files changed, 515 insertions(+), 16 deletions(-) create mode 100644 packages/wallet-cli/src/commands/wallet/unlock.test.ts create mode 100644 packages/wallet-cli/src/commands/wallet/unlock.ts diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index 1e7630ff7b..88218f7af3 100644 --- a/packages/wallet-cli/CHANGELOG.md +++ b/packages/wallet-cli/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `mm daemon start` spawns the daemon with `--infura-project-id`, `--password`, `--srp` (or the matching env vars). - `mm daemon call []` dispatches any messenger action over JSON-RPC. - `mm daemon stop`, `mm daemon status`, `mm daemon purge` manage daemon lifecycle and state. -- Persist daemon state to a SQLite database at `/wallet.db`; subsequent `daemon start` runs reuse the persisted KeyringController vault instead of re-importing the SRP, and the daemon auto-unlocks the keyring with the supplied password so keyring-bound messenger actions (signing, `AccountsController:listAccounts`, etc.) work immediately after a restart. `--password` is optional on subsequent runs ([#8446](https://github.com/MetaMask/core/pull/8446)). + - `mm wallet unlock` submits the password to the running daemon for cases where it was started without one (or after the keyring was locked via `KeyringController:setLocked`). Accepts `--password `, falls back to `MM_WALLET_PASSWORD`, and prompts interactively when neither is supplied. +- Persist daemon state to a SQLite database at `/wallet.db`; subsequent `daemon start` runs reuse the persisted KeyringController vault instead of re-importing the SRP, and the daemon auto-unlocks the keyring with the supplied password so keyring-bound messenger actions (signing, `AccountsController:listAccounts`, etc.) work immediately after a restart. `--password` is optional on subsequent runs: omit it to start with a locked keyring and unlock later with `mm wallet unlock` ([#8446](https://github.com/MetaMask/core/pull/8446)). [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index 000c5b4a7e..645c25eb61 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@inquirer/confirm": "^6.0.11", + "@inquirer/password": "^4.0.16", "@metamask/remote-feature-flag-controller": "^4.2.0", "@metamask/rpc-errors": "^7.0.2", "@metamask/utils": "^11.9.0", diff --git a/packages/wallet-cli/src/commands/wallet/unlock.test.ts b/packages/wallet-cli/src/commands/wallet/unlock.test.ts new file mode 100644 index 0000000000..899ba11366 --- /dev/null +++ b/packages/wallet-cli/src/commands/wallet/unlock.test.ts @@ -0,0 +1,206 @@ +import { sendCommand } from '../../daemon/daemon-client'; +import { promptPassword } from '../../daemon/prompts'; +import { runCommand } from '../../test/run-command'; +import WalletUnlock from './unlock'; + +jest.mock('../../daemon/daemon-client'); +jest.mock('../../daemon/prompts'); + +const mockSendCommand = jest.mocked(sendCommand); +const mockPromptPassword = jest.mocked(promptPassword); + +const SUCCESS_FLAGS = ['--password', 'pw']; + +describe('wallet unlock', () => { + beforeEach(() => { + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: null, + }); + }); + + it('dispatches KeyringController:submitPassword with the password', async () => { + await runCommand(WalletUnlock, SUCCESS_FLAGS); + + expect(mockSendCommand).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'call', + params: ['KeyringController:submitPassword', 'pw'], + }), + ); + expect(mockPromptPassword).not.toHaveBeenCalled(); + }); + + it('reports success on a non-error response', async () => { + const { stdout } = await runCommand(WalletUnlock, SUCCESS_FLAGS); + + expect(stdout).toContain('Wallet unlocked.'); + }); + + it('passes the timeout flag through to sendCommand', async () => { + await runCommand(WalletUnlock, [...SUCCESS_FLAGS, '--timeout', '5000']); + + expect(mockSendCommand).toHaveBeenCalledWith( + expect.objectContaining({ timeoutMs: 5000 }), + ); + }); + + it('reads the password from MM_WALLET_PASSWORD when the flag is absent', async () => { + // Snapshot+restore the whole env via assignment so the await between + // mutation and restore does not trip `require-atomic-updates`. + const savedEnv = process.env; + process.env = { ...savedEnv, MM_WALLET_PASSWORD: 'from-env' }; + try { + await runCommand(WalletUnlock, []); + } finally { + // Restoring after await is intentional; jest runs each test serially. + // eslint-disable-next-line require-atomic-updates + process.env = savedEnv; + } + + expect(mockSendCommand).toHaveBeenCalledWith( + expect.objectContaining({ + params: ['KeyringController:submitPassword', 'from-env'], + }), + ); + expect(mockPromptPassword).not.toHaveBeenCalled(); + }); + + it('prompts interactively when neither flag nor env is supplied', async () => { + const savedEnv = process.env; + process.env = { ...savedEnv }; + delete process.env.MM_WALLET_PASSWORD; + mockPromptPassword.mockResolvedValue('typed-by-user'); + try { + await runCommand(WalletUnlock, []); + } finally { + // eslint-disable-next-line require-atomic-updates + process.env = savedEnv; + } + + expect(mockPromptPassword).toHaveBeenCalledTimes(1); + expect(mockSendCommand).toHaveBeenCalledWith( + expect.objectContaining({ + params: ['KeyringController:submitPassword', 'typed-by-user'], + }), + ); + }); + + it('prompts interactively when --password is supplied with an empty value', async () => { + // `--password ''` satisfies the flag without giving a real password; + // the command treats it the same as "no flag" and prompts. Otherwise + // the controller would reject `''` as a wrong password, which is a + // worse UX than re-prompting. + mockPromptPassword.mockResolvedValue('typed-by-user'); + + await runCommand(WalletUnlock, ['--password', '']); + + expect(mockPromptPassword).toHaveBeenCalledTimes(1); + expect(mockSendCommand).toHaveBeenCalledWith( + expect.objectContaining({ + params: ['KeyringController:submitPassword', 'typed-by-user'], + }), + ); + }); + + it('returns a friendly hint when the daemon is not running (ENOENT)', async () => { + mockSendCommand.mockRejectedValue( + Object.assign(new Error('no such file'), { code: 'ENOENT' }), + ); + + const { error } = await runCommand(WalletUnlock, SUCCESS_FLAGS); + + expect(error?.message).toContain('Daemon is not running'); + }); + + it('returns a friendly hint when the daemon refuses the connection', async () => { + mockSendCommand.mockRejectedValue( + Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }), + ); + + const { error } = await runCommand(WalletUnlock, SUCCESS_FLAGS); + + expect(error?.message).toContain('Daemon is not running'); + }); + + it('surfaces other socket errors with the raw message', async () => { + mockSendCommand.mockRejectedValue(new Error('Socket read timed out')); + + const { error } = await runCommand(WalletUnlock, SUCCESS_FLAGS); + + expect(error?.message).toContain('Socket read timed out'); + }); + + it('handles non-Error throws from sendCommand', async () => { + mockSendCommand.mockImplementation(async () => + Promise.reject('string error' as unknown as Error), + ); + + const { error } = await runCommand(WalletUnlock, SUCCESS_FLAGS); + + expect(error?.message).toContain('string error'); + }); + + it('errors with the JSON-RPC failure when submitPassword rejects', async () => { + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + error: { code: -32000, message: 'Incorrect password' }, + }); + + const { error } = await runCommand(WalletUnlock, SUCCESS_FLAGS); + + expect(error?.message).toContain('Failed to unlock'); + expect(error?.message).toContain('Incorrect password'); + expect(error?.message).toContain('-32000'); + }); + + it('surfaces the `data` field when the JSON-RPC failure carries one', async () => { + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + error: { + code: -32000, + message: 'Incorrect password', + data: { attemptsRemaining: 2 }, + }, + }); + + const { error } = await runCommand(WalletUnlock, SUCCESS_FLAGS); + + expect(error?.message).toContain('attemptsRemaining'); + }); + + it('returns a permission-specific hint when the socket is unreadable (EACCES)', async () => { + mockSendCommand.mockRejectedValue( + Object.assign(new Error('permission denied'), { code: 'EACCES' }), + ); + + const { error } = await runCommand(WalletUnlock, SUCCESS_FLAGS); + + expect(error?.message).toContain('permission denied'); + expect(error?.message).toContain('MM_DAEMON_DATA_DIR'); + }); + + it('is idempotent: re-running unlock against an already-unlocked daemon succeeds', async () => { + // The keyring controller's `submitPassword` is a no-op when the vault + // is already unlocked (returns the unlocked state). Asserting that a + // second `mm wallet unlock` invocation reports "Wallet unlocked" + // pins the contract so a future change can't silently make + // re-unlocking fail (which would be a UX trap when a user re-runs + // the command not knowing the wallet is already unlocked). + const { stdout: firstStdout } = await runCommand( + WalletUnlock, + SUCCESS_FLAGS, + ); + const { stdout: secondStdout } = await runCommand( + WalletUnlock, + SUCCESS_FLAGS, + ); + + expect(firstStdout).toContain('Wallet unlocked.'); + expect(secondStdout).toContain('Wallet unlocked.'); + expect(mockSendCommand).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/wallet-cli/src/commands/wallet/unlock.ts b/packages/wallet-cli/src/commands/wallet/unlock.ts new file mode 100644 index 0000000000..87d2788492 --- /dev/null +++ b/packages/wallet-cli/src/commands/wallet/unlock.ts @@ -0,0 +1,90 @@ +import { isJsonRpcFailure } from '@metamask/utils'; +import { Command, Flags } from '@oclif/core'; + +import { sendCommand } from '../../daemon/daemon-client'; +import { getDaemonPaths } from '../../daemon/paths'; +import { promptPassword } from '../../daemon/prompts'; +import { isErrorWithCode } from '../../daemon/utils'; + +export default class WalletUnlock extends Command { + static override description = + 'Unlock the wallet (submits the password to KeyringController). ' + + 'Use this after `mm daemon start` was run without `--password`, or ' + + 'after the keyring was locked via `KeyringController:setLocked`.'; + + static override examples = [ + '<%= config.bin %> wallet unlock --password ', + 'MM_WALLET_PASSWORD= <%= config.bin %> wallet unlock', + '<%= config.bin %> wallet unlock # prompts interactively', + ]; + + static override flags = { + password: Flags.string({ + description: + 'Wallet password (testing only — use MM_WALLET_PASSWORD env var in production). ' + + 'When omitted, the command prompts interactively.', + env: 'MM_WALLET_PASSWORD', + required: false, + }), + timeout: Flags.integer({ + char: 't', + description: 'Response timeout in milliseconds', + required: false, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(WalletUnlock); + const { timeout: timeoutMs } = flags; + + // Empty `--password ''` or empty `MM_WALLET_PASSWORD` env var means "no + // password supplied", not "the empty string is the password" — + // collapsing the ambiguity here so the prompt fires instead of sending + // an empty string the controller will reject. + const flagPassword = + flags.password === undefined || flags.password === '' + ? undefined + : flags.password; + const password = flagPassword ?? (await promptPassword()); + + const { socketPath } = getDaemonPaths(this.config.dataDir); + + let response; + try { + response = await sendCommand({ + socketPath, + method: 'call', + params: ['KeyringController:submitPassword', password], + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }); + } catch (error) { + if ( + isErrorWithCode(error, 'ENOENT') || + isErrorWithCode(error, 'ECONNREFUSED') + ) { + this.error('Daemon is not running. Start it with `mm daemon start`.'); + } + if (isErrorWithCode(error, 'EACCES')) { + this.error( + `Cannot connect to the daemon socket: permission denied. ` + + `The socket may be owned by another user, or MM_DAEMON_DATA_DIR ` + + `may point to a directory you cannot access.`, + ); + } + this.error(error instanceof Error ? error.message : String(error)); + } + + if (isJsonRpcFailure(response)) { + const { code, message, data } = response.error; + // `isJsonRpcFailure` already validates that `data` is JSON, so + // `JSON.stringify` cannot throw here. + const dataSuffix = + data === undefined ? '' : ` data=${JSON.stringify(data)}`; + this.error( + `Failed to unlock: ${message} (code ${String(code)})${dataSuffix}`, + ); + } + + this.log('Wallet unlocked.'); + } +} diff --git a/packages/wallet-cli/src/daemon/prompts.test.ts b/packages/wallet-cli/src/daemon/prompts.test.ts index 6ec63c50d2..b5671a0aae 100644 --- a/packages/wallet-cli/src/daemon/prompts.test.ts +++ b/packages/wallet-cli/src/daemon/prompts.test.ts @@ -1,15 +1,22 @@ -// `@inquirer/confirm` is ESM-only and `prompts.ts` reaches it via a dynamic -// `import()`. Use jest's ESM mock API and dynamic imports to mirror that. -// The import statement below is what tags this file as a module for the -// `import-x/unambiguous` lint rule, even though it imports only the type. +// `@inquirer/confirm` and `@inquirer/password` are ESM-only and `prompts.ts` +// reaches them via dynamic `import()`. Use jest's ESM mock API and dynamic +// imports to mirror that. The import statements below tag this file as a +// module for the `import-x/unambiguous` lint rule, even though they import +// only types. import type Confirm from '@inquirer/confirm'; +import type Password from '@inquirer/password'; jest.unstable_mockModule('@inquirer/confirm', () => ({ __esModule: true, default: jest.fn(), })); +jest.unstable_mockModule('@inquirer/password', () => ({ + __esModule: true, + default: jest.fn(), +})); type ConfirmMock = jest.MockedFunction; +type PasswordMock = jest.MockedFunction; describe('confirmPurge', () => { it('invokes @inquirer/confirm with the purge prompt and returns its result', async () => { @@ -36,3 +43,20 @@ describe('confirmPurge', () => { expect(await confirmPurge()).toBe(false); }); }); + +describe('promptPassword', () => { + it('invokes @inquirer/password with masked input and returns the user input', async () => { + const password = (await import('@inquirer/password')) + .default as unknown as PasswordMock; + password.mockResolvedValue('hunter2'); + const { promptPassword } = await import('./prompts'); + + const result = await promptPassword(); + + expect(result).toBe('hunter2'); + expect(password).toHaveBeenCalledWith({ + message: 'Wallet password:', + mask: true, + }); + }); +}); diff --git a/packages/wallet-cli/src/daemon/prompts.ts b/packages/wallet-cli/src/daemon/prompts.ts index 4245b68050..3db2959a1f 100644 --- a/packages/wallet-cli/src/daemon/prompts.ts +++ b/packages/wallet-cli/src/daemon/prompts.ts @@ -14,3 +14,19 @@ export async function confirmPurge(): Promise { default: false, }); } + +/** + * Prompt the user for the wallet password, with input masked. Used by + * `mm wallet unlock` when the user did not pass `--password` or set the + * `MM_WALLET_PASSWORD` env var. Same dynamic-import + ESM-interop pattern + * as {@link confirmPurge}. + * + * @returns The password the user typed. + */ +export async function promptPassword(): Promise { + const { default: password } = await import('@inquirer/password'); + return password({ + message: 'Wallet password:', + mask: true, + }); +} diff --git a/yarn.lock b/yarn.lock index be0f14d051..120b76d2c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1792,6 +1792,13 @@ __metadata: languageName: node linkType: hard +"@inquirer/ansi@npm:^1.0.2": + version: 1.0.2 + resolution: "@inquirer/ansi@npm:1.0.2" + checksum: 10/d1496e573a63ee6752bcf3fc93375cdabc55b0d60f0588fe7902282c710b223252ad318ff600ee904e48555634663b53fda517f5b29ce9fbda90bfae18592fbc + languageName: node + linkType: hard + "@inquirer/ansi@npm:^2.0.3, @inquirer/ansi@npm:^2.0.5": version: 2.0.5 resolution: "@inquirer/ansi@npm:2.0.5" @@ -1859,6 +1866,27 @@ __metadata: languageName: node linkType: hard +"@inquirer/core@npm:^10.3.2": + version: 10.3.2 + resolution: "@inquirer/core@npm:10.3.2" + dependencies: + "@inquirer/ansi": "npm:^1.0.2" + "@inquirer/figures": "npm:^1.0.15" + "@inquirer/type": "npm:^3.0.10" + cli-width: "npm:^4.1.0" + mute-stream: "npm:^2.0.0" + signal-exit: "npm:^4.1.0" + wrap-ansi: "npm:^6.2.0" + yoctocolors-cjs: "npm:^2.1.3" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10/eb434bdf0ae7d904367003c772bcd80cbf679f79c087c99a4949fd7288e9a2f713ec3ea63381b9a001f52389ab56a77fcd88d64d81a03b1195193410ce8971c2 + languageName: node + linkType: hard + "@inquirer/core@npm:^11.1.4, @inquirer/core@npm:^11.1.8": version: 11.1.8 resolution: "@inquirer/core@npm:11.1.8" @@ -1925,6 +1953,13 @@ __metadata: languageName: node linkType: hard +"@inquirer/figures@npm:^1.0.15": + version: 1.0.15 + resolution: "@inquirer/figures@npm:1.0.15" + checksum: 10/3f858807f361ca29f41ec1076bbece4098cc140d86a06159d42c6e3f6e4d9bec9e10871ccfcbbaa367d6a8462b01dff89f2b1b157d9de6e8726bec85533f525c + languageName: node + linkType: hard + "@inquirer/figures@npm:^2.0.3, @inquirer/figures@npm:^2.0.5": version: 2.0.5 resolution: "@inquirer/figures@npm:2.0.5" @@ -1972,6 +2007,22 @@ __metadata: languageName: node linkType: hard +"@inquirer/password@npm:^4.0.16": + version: 4.0.23 + resolution: "@inquirer/password@npm:4.0.23" + dependencies: + "@inquirer/ansi": "npm:^1.0.2" + "@inquirer/core": "npm:^10.3.2" + "@inquirer/type": "npm:^3.0.10" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10/97364970b01c85946a4a50ad876c53ef0c1857a9144e24fad65e5dfa4b4e5dd42564fbcdfa2b49bb049a25d127efbe0882cb18afcdd47b166ebd01c6c4b5e825 + languageName: node + linkType: hard + "@inquirer/password@npm:^5.0.5": version: 5.0.7 resolution: "@inquirer/password@npm:5.0.7" @@ -2059,6 +2110,18 @@ __metadata: languageName: node linkType: hard +"@inquirer/type@npm:^3.0.10": + version: 3.0.10 + resolution: "@inquirer/type@npm:3.0.10" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10/57d113a9db7abc73326491e29bedc88ef362e53779f9f58a1b61225e0be068ce0c54e33cd65f4a13ca46131676fb72c3ef488463c4c9af0aa89680684c55d74c + languageName: node + linkType: hard + "@inquirer/type@npm:^4.0.3, @inquirer/type@npm:^4.0.5": version: 4.0.5 resolution: "@inquirer/type@npm:4.0.5" @@ -3720,6 +3783,25 @@ __metadata: languageName: node linkType: hard +"@metamask/eth-hd-keyring@npm:^14.1.1": + version: 14.1.1 + resolution: "@metamask/eth-hd-keyring@npm:14.1.1" + dependencies: + "@ethereumjs/tx": "npm:^5.4.0" + "@ethereumjs/util": "npm:^9.1.0" + "@metamask/eth-sig-util": "npm:^8.2.0" + "@metamask/key-tree": "npm:^10.0.2" + "@metamask/keyring-api": "npm:^23.1.0" + "@metamask/keyring-sdk": "npm:^2.0.2" + "@metamask/keyring-utils": "npm:^3.2.0" + "@metamask/scure-bip39": "npm:^2.1.1" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.11.0" + ethereum-cryptography: "npm:^2.2.1" + checksum: 10/f711742682a77990310272013523595822e29bfb61980828d1460ab8af888d7c344bb50f04d2611d801d63438e31532d3d66698c595b4fd3ad512b02056b7234 + languageName: node + linkType: hard + "@metamask/eth-json-rpc-filters@npm:^9.0.0": version: 9.0.0 resolution: "@metamask/eth-json-rpc-filters@npm:9.0.0" @@ -3850,18 +3932,18 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-simple-keyring@npm:^12.0.1": - version: 12.0.1 - resolution: "@metamask/eth-simple-keyring@npm:12.0.1" +"@metamask/eth-simple-keyring@npm:^12.0.2": + version: 12.0.2 + resolution: "@metamask/eth-simple-keyring@npm:12.0.2" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/keyring-api": "npm:^23.0.1" - "@metamask/keyring-sdk": "npm:^2.0.1" + "@metamask/keyring-api": "npm:^23.1.0" + "@metamask/keyring-sdk": "npm:^2.0.2" "@metamask/utils": "npm:^11.11.0" ethereum-cryptography: "npm:^2.2.1" randombytes: "npm:^2.1.0" - checksum: 10/7bedb102e89a3dd3ade935a9c8b02dc6237778bab276e7a2144d4a0f5c2a1fe0c6dd85756c1014fe0b4cd0936fdcc60d53fd00c3d35fa880e49b2aaf840f2521 + checksum: 10/ac8a3a5871fb1b7503ae8714beaad07102ad473522f1c65cf09786a3f3498564763d96a51569ed712702f3a62dfaada4c9342def4d48e2c5a1f0985b5cd49725 languageName: node linkType: hard @@ -4223,6 +4305,18 @@ __metadata: languageName: node linkType: hard +"@metamask/keyring-api@npm:^23.1.0": + version: 23.1.0 + resolution: "@metamask/keyring-api@npm:23.1.0" + dependencies: + "@metamask/keyring-utils": "npm:^3.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.11.0" + bitcoin-address-validation: "npm:^2.2.3" + checksum: 10/ca7e1b49f8c30078ebd76f1f7e74c57846ac3e73ffdc2f7b446af6cb3c006cef75a13eec6a0aeae0bfefbb5549d576af410bb9df16196fcefe17ded64f8714f2 + languageName: node + linkType: hard + "@metamask/keyring-controller@npm:^25.2.0, @metamask/keyring-controller@workspace:packages/keyring-controller": version: 0.0.0-use.local resolution: "@metamask/keyring-controller@workspace:packages/keyring-controller" @@ -4235,12 +4329,12 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/browser-passworder": "npm:^6.0.0" - "@metamask/eth-hd-keyring": "npm:^14.1.0" + "@metamask/eth-hd-keyring": "npm:^14.1.1" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/eth-simple-keyring": "npm:^12.0.1" - "@metamask/keyring-api": "npm:^23.0.1" - "@metamask/keyring-internal-api": "npm:^11.0.0" - "@metamask/keyring-utils": "npm:^3.1.0" + "@metamask/eth-simple-keyring": "npm:^12.0.2" + "@metamask/keyring-api": "npm:^23.1.0" + "@metamask/keyring-internal-api": "npm:^11.0.1" + "@metamask/keyring-utils": "npm:^3.2.1" "@metamask/messenger": "npm:^1.2.0" "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/utils": "npm:^11.9.0" @@ -4274,6 +4368,17 @@ __metadata: languageName: node linkType: hard +"@metamask/keyring-internal-api@npm:^11.0.1": + version: 11.0.1 + resolution: "@metamask/keyring-internal-api@npm:11.0.1" + dependencies: + "@metamask/keyring-api": "npm:^23.1.0" + "@metamask/keyring-utils": "npm:^3.2.0" + "@metamask/superstruct": "npm:^3.1.0" + checksum: 10/d35a5b45887566f47e41c1109e9f4eca5108fd1b8402773d40a882c904de0f33cc70e0cc7d1e1a34397ae434646c597d9793c31dbedc83cdf64e41f8e719ccc4 + languageName: node + linkType: hard + "@metamask/keyring-internal-snap-client@npm:^10.0.2": version: 10.0.2 resolution: "@metamask/keyring-internal-snap-client@npm:10.0.2" @@ -4305,6 +4410,24 @@ __metadata: languageName: node linkType: hard +"@metamask/keyring-sdk@npm:^2.0.2": + version: 2.1.1 + resolution: "@metamask/keyring-sdk@npm:2.1.1" + dependencies: + "@ethereumjs/tx": "npm:^5.4.0" + "@metamask/eth-sig-util": "npm:^8.2.0" + "@metamask/keyring-api": "npm:^23.1.0" + "@metamask/keyring-utils": "npm:^3.3.1" + "@metamask/scure-bip39": "npm:^2.1.1" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.11.0" + async-mutex: "npm:^0.5.0" + ethereum-cryptography: "npm:^2.2.1" + uuid: "npm:^9.0.1" + checksum: 10/bd10f41e124a61dd53c3914ab8f53e3519bc90905668f83e386bd0c7053754e446396a39b88f88228b2a001ee02762b287495e284ff3052ff5b7636803ac437b + languageName: node + linkType: hard + "@metamask/keyring-snap-client@npm:^9.0.1": version: 9.0.1 resolution: "@metamask/keyring-snap-client@npm:9.0.1" @@ -4349,6 +4472,18 @@ __metadata: languageName: node linkType: hard +"@metamask/keyring-utils@npm:^3.2.1, @metamask/keyring-utils@npm:^3.3.1": + version: 3.3.1 + resolution: "@metamask/keyring-utils@npm:3.3.1" + dependencies: + "@ethereumjs/tx": "npm:^5.4.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.11.0" + bitcoin-address-validation: "npm:^2.2.3" + checksum: 10/d0917b2f634d9eb2f563827739fca00c1675ee90674ec49b2b68afcb604aee843d6c5be5d79e9780fa22d29f71fc876f40b2d3d0ed4cab7f5948937ddd276691 + languageName: node + linkType: hard + "@metamask/logging-controller@npm:^8.0.1, @metamask/logging-controller@workspace:packages/logging-controller": version: 0.0.0-use.local resolution: "@metamask/logging-controller@workspace:packages/logging-controller" @@ -5817,6 +5952,7 @@ __metadata: resolution: "@metamask/wallet-cli@workspace:packages/wallet-cli" dependencies: "@inquirer/confirm": "npm:^6.0.11" + "@inquirer/password": "npm:^4.0.16" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.0" "@metamask/rpc-errors": "npm:^7.0.2" @@ -12872,6 +13008,13 @@ __metadata: languageName: node linkType: hard +"mute-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "mute-stream@npm:2.0.0" + checksum: 10/d2e4fd2f5aa342b89b98134a8d899d8ef9b0a6d69274c4af9df46faa2d97aeb1f2ce83d867880d6de63643c52386579b99139801e24e7526c3b9b0a6d1e18d6c + languageName: node + linkType: hard + "mute-stream@npm:^3.0.0": version: 3.0.0 resolution: "mute-stream@npm:3.0.0" @@ -15711,6 +15854,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^6.2.0": + version: 6.2.0 + resolution: "wrap-ansi@npm:6.2.0" + dependencies: + ansi-styles: "npm:^4.0.0" + string-width: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + checksum: 10/0d64f2d438e0b555e693b95aee7b2689a12c3be5ac458192a1ce28f542a6e9e59ddfecc37520910c2c88eb1f82a5411260566dba5064e8f9895e76e169e76187 + languageName: node + linkType: hard + "wrap-ansi@npm:^8.1.0": version: 8.1.0 resolution: "wrap-ansi@npm:8.1.0" @@ -15938,3 +16092,10 @@ __metadata: checksum: 10/f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700 languageName: node linkType: hard + +"yoctocolors-cjs@npm:^2.1.3": + version: 2.1.3 + resolution: "yoctocolors-cjs@npm:2.1.3" + checksum: 10/b2144b38807673a4254dae06fe1a212729550609e606289c305e45c585b36fab1dbba44fe6cde90db9b28be465ec63f4c2a50867aeec6672f6bc36b6c9a361a0 + languageName: node + linkType: hard