diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cdaba956e65..d834e3a3732 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -100,6 +100,7 @@ /packages/rate-limit-controller @MetaMask/core-platform /packages/react-data-query @MetaMask/core-platform /packages/profile-metrics-controller @MetaMask/core-platform +/packages/wallet @MetaMask/core-platform ## Web3Auth Team /packages/seedless-onboarding-controller @MetaMask/web3auth diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 7035a90e6e3..09faaeb6f0c 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -143,6 +143,9 @@ jobs: matrix: node-version: [18.x, 20.x, 22.x] package-name: ${{ fromJson(needs.prepare.outputs.child-workspace-package-names) }} + exclude: + - node-version: 18.x + package-name: '@metamask/wallet' steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v2 diff --git a/.gitignore b/.gitignore index 2f1de082398..7301fa60a66 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,13 @@ scripts/coverage # typescript packages/*/*.tsbuildinfo +# foundryup binary cache +.metamask/ + # AI -.sisyphus/ \ No newline at end of file +.sisyphus/ + +# Wallet +.claude/ +.env +!.env.example diff --git a/README.md b/README.md index c2fa40aacc2..fd6fa92f4ec 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/transaction-controller`](packages/transaction-controller) - [`@metamask/transaction-pay-controller`](packages/transaction-pay-controller) - [`@metamask/user-operation-controller`](packages/user-operation-controller) +- [`@metamask/wallet`](packages/wallet) @@ -195,6 +196,7 @@ linkStyle default opacity:0.5 transaction_controller(["@metamask/transaction-controller"]); transaction_pay_controller(["@metamask/transaction-pay-controller"]); user_operation_controller(["@metamask/user-operation-controller"]); + wallet(["@metamask/wallet"]); account_tree_controller --> accounts_controller; account_tree_controller --> base_controller; account_tree_controller --> keyring_controller; @@ -553,6 +555,16 @@ linkStyle default opacity:0.5 user_operation_controller --> polling_controller; user_operation_controller --> transaction_controller; user_operation_controller --> eth_block_tracker; + wallet --> accounts_controller; + wallet --> approval_controller; + wallet --> connectivity_controller; + wallet --> controller_utils; + wallet --> keyring_controller; + wallet --> messenger; + wallet --> network_controller; + wallet --> remote_feature_flag_controller; + wallet --> transaction_controller; + wallet --> foundryup; ``` diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 21f85f7a3c0..558c8628fcd 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2349,6 +2349,16 @@ "count": 1 } }, + "packages/wallet/src/initialization/instances/accounts-controller.ts": { + "no-restricted-syntax": { + "count": 2 + } + }, + "packages/wallet/src/initialization/instances/transaction-controller.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "tests/fake-provider.ts": { "no-restricted-syntax": { "count": 7 diff --git a/eslint.config.mjs b/eslint.config.mjs index 85f596606b8..6e9b5868bd6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -181,6 +181,12 @@ const config = createConfig([ }, }, }, + { + files: ['packages/wallet/**/*.test.ts'], + rules: { + 'n/no-process-env': 'off', + }, + }, { // These files are test helpers, not tests. We still use the Jest ESLint // config here to ensure that ESLint expects a test-like environment, but diff --git a/package.json b/package.json index 706b0d9648f..745f6cb6b25 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,8 @@ "babel-runtime>core-js": false, "simple-git-hooks": false, "tsx>esbuild": false, - "eslint-plugin-import-x>unrs-resolver": false + "eslint-plugin-import-x>unrs-resolver": false, + "better-sqlite3": true } } } diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md new file mode 100644 index 00000000000..b518709c7b8 --- /dev/null +++ b/packages/wallet/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/wallet/LICENSE b/packages/wallet/LICENSE new file mode 100644 index 00000000000..f9f85c6d4ec --- /dev/null +++ b/packages/wallet/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/wallet/LICENSE.APACHE2 b/packages/wallet/LICENSE.APACHE2 new file mode 100644 index 00000000000..18002eac9ae --- /dev/null +++ b/packages/wallet/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 2026 MetaMask + + 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. \ No newline at end of file diff --git a/packages/wallet/LICENSE.MIT b/packages/wallet/LICENSE.MIT new file mode 100644 index 00000000000..e0278643409 --- /dev/null +++ b/packages/wallet/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. \ No newline at end of file diff --git a/packages/wallet/README.md b/packages/wallet/README.md new file mode 100644 index 00000000000..bc3cf7e5c1b --- /dev/null +++ b/packages/wallet/README.md @@ -0,0 +1,25 @@ +# `@metamask/wallet` + +Provides a shared framework for building MetaMask wallets + +## Installation + +`yarn add @metamask/wallet` + +or + +`npm install @metamask/wallet` + +## Troubleshooting + +### Rebuilding `better-sqlite3` + +This package depends on `better-sqlite3`, which includes a native C addon. The prebuilt binary is downloaded automatically during `yarn install`. If you switch Node versions or branches and the binding is missing, rebuild it with: + +```sh +cd node_modules/better-sqlite3 && npx prebuild-install +``` + +## 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/wallet/jest.config.js b/packages/wallet/jest.config.js new file mode 100644 index 00000000000..882e87fd88b --- /dev/null +++ b/packages/wallet/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: 85, + functions: 80, + lines: 90, + statements: 90, + }, + }, +}); diff --git a/packages/wallet/package.json b/packages/wallet/package.json new file mode 100644 index 00000000000..97bce884081 --- /dev/null +++ b/packages/wallet/package.json @@ -0,0 +1,99 @@ +{ + "name": "@metamask/wallet", + "version": "0.0.0", + "description": "Provides a shared framework for building MetaMask wallets", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/wallet#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "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" + } + }, + "./persistence": { + "import": { + "types": "./dist/persistence/index.d.mts", + "default": "./dist/persistence/index.mjs" + }, + "require": { + "types": "./dist/persistence/index.d.cts", + "default": "./dist/persistence/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/wallet", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/wallet", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test:prepare": "./scripts/install-binaries.sh", + "test": "yarn test:prepare && 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/accounts-controller": "^37.2.0", + "@metamask/approval-controller": "^9.0.1", + "@metamask/browser-passworder": "^6.0.0", + "@metamask/connectivity-controller": "^0.2.0", + "@metamask/controller-utils": "^11.20.0", + "@metamask/keyring-controller": "^25.2.0", + "@metamask/messenger": "^1.2.0", + "@metamask/network-controller": "^30.0.1", + "@metamask/remote-feature-flag-controller": "^4.2.0", + "@metamask/scure-bip39": "^2.1.1", + "@metamask/transaction-controller": "^65.0.0", + "@metamask/utils": "^11.9.0", + "better-sqlite3": "^12.9.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@metamask/foundryup": "^1.0.1", + "@ts-bridge/cli": "^0.6.4", + "@types/better-sqlite3": "^7.6.13", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "nock": "^13.3.1", + "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/wallet/scripts/install-binaries.sh b/packages/wallet/scripts/install-binaries.sh new file mode 100755 index 00000000000..cf087190a59 --- /dev/null +++ b/packages/wallet/scripts/install-binaries.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -e +set -o pipefail + +# Pin cwd to the package root so all paths are predictable regardless of how +# this script is invoked. Also derive the monorepo root (two levels up). +PACKAGE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MONOREPO_ROOT="$(cd "${PACKAGE_ROOT}/../.." && pwd)" +cd "${PACKAGE_ROOT}" + +# Run foundryup's TypeScript entry point directly via tsx. This avoids having +# to build @metamask/foundryup first, which matters in CI where workspace deps +# aren't built before tests run. +if ! output=$(yarn tsx ../foundryup/src/cli.ts --binaries anvil 2>&1); then + echo "$output" >&2 + exit 1 +fi + +# Install the better-sqlite3 native addon if missing. Yarn has +# `enableScripts: false` globally, so install scripts never run during +# `yarn install` and the addon may be absent from the filesystem. Invoke the +# prebuild-install binary directly to fetch a matching prebuild for the active +# Node version and platform. +BETTER_SQLITE3_DIR="${MONOREPO_ROOT}/node_modules/better-sqlite3" +if [ ! -f "${BETTER_SQLITE3_DIR}/build/Release/better_sqlite3.node" ]; then + ( + cd "${BETTER_SQLITE3_DIR}" + "${MONOREPO_ROOT}/node_modules/.bin/prebuild-install" + ) +fi diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts new file mode 100644 index 00000000000..df9e53ede04 --- /dev/null +++ b/packages/wallet/src/Wallet.test.ts @@ -0,0 +1,258 @@ +import { RpcEndpointType } from '@metamask/network-controller'; +import { + ClientConfigApiService, + ClientType, + DistributionType, + EnvironmentType, +} from '@metamask/remote-feature-flag-controller'; +import { TransactionController } from '@metamask/transaction-controller'; +import { enableNetConnect } from 'nock'; + +import { startAnvil } from '../test/anvil'; +import type { AnvilInstance } from '../test/anvil'; +import * as initializationModule from './initialization'; +import { + createSecretRecoveryPhrase, + importSecretRecoveryPhrase, + sendTransaction, +} from './utilities'; +import { Wallet } from './Wallet'; + +const TEST_PHRASE = + 'test test test test test test test test test test test ball'; +const TEST_PASSWORD = 'testpass'; + +async function setupWallet(): Promise { + const wallet = new Wallet({ + infuraProjectId: 'fake-infura-project-id', + clientVersion: '1.0.0', + showApprovalRequest: (): undefined => undefined, + clientConfigApiService: new ClientConfigApiService({ + fetch: globalThis.fetch, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }), + getMetaMetricsId: (): string => 'fake-metrics-id', + }); + + await importSecretRecoveryPhrase(wallet, TEST_PASSWORD, TEST_PHRASE); + + return wallet; +} + +describe('Wallet', () => { + let wallet: Wallet; + + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + + afterEach(async () => { + await wallet?.destroy(); + enableNetConnect(); + jest.useRealTimers(); + }); + + it('can unlock and populate accounts', async () => { + wallet = await setupWallet(); + const { messenger } = wallet; + + expect( + messenger + .call('AccountsController:listAccounts') + .map((account) => account.address), + ).toStrictEqual(['0xc6d5a3c98ec9073b54fa0969957bd582e8d874bf']); + }); + + describe('with local chain', () => { + let anvil: AnvilInstance; + + beforeAll(async () => { + anvil = await startAnvil({ mnemonic: TEST_PHRASE }); + }); + + afterAll(async () => { + await anvil?.stop(); + }); + + it('signs transactions', async () => { + enableNetConnect(); + + wallet = await setupWallet(); + + const networkConfig = wallet.messenger.call( + 'NetworkController:addNetwork', + { + chainId: '0x7a69', + name: 'Anvil', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + url: anvil.rpcUrl, + }, + ], + }, + ); + + const { networkClientId } = networkConfig.rpcEndpoints[0]; + + const addresses = wallet.messenger + .call('AccountsController:listAccounts') + .map((account) => account.address); + + const { result, transactionMeta } = await sendTransaction( + wallet, + { from: addresses[0], to: addresses[0], data: '0x00' }, + { networkClientId }, + ); + + // Advance timers by an arbitrary value to trigger downstream timer logic. + const hash = await jest + .advanceTimersByTimeAsync(60_000) + .then(() => result); + + expect(hash).toStrictEqual(expect.any(String)); + expect(transactionMeta).toStrictEqual( + expect.objectContaining({ + txParams: expect.objectContaining({ + from: addresses[0], + to: addresses[0], + data: '0x00', + value: '0x0', + type: '0x2', + }), + }), + ); + }, 15_000); + }); + + it('can create secret recovery phrase', async () => { + wallet = new Wallet({ + infuraProjectId: 'fake-infura-project-id', + clientVersion: '1.0.0', + showApprovalRequest: (): undefined => undefined, + clientConfigApiService: new ClientConfigApiService({ + fetch: globalThis.fetch, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }), + getMetaMetricsId: (): string => 'fake-metrics-id', + }); + + await createSecretRecoveryPhrase(wallet, TEST_PASSWORD); + + expect( + wallet.messenger.call('AccountsController:listAccounts'), + ).toHaveLength(1); + }); + + it('exposes state', async () => { + wallet = await setupWallet(); + const { state } = wallet; + + expect(state.KeyringController).toStrictEqual({ + isUnlocked: true, + keyrings: expect.any(Array), + encryptionKey: expect.any(String), + encryptionSalt: expect.any(String), + vault: expect.any(String), + }); + }); + + describe('lifecycle', () => { + const options = { + infuraProjectId: 'fake-infura-project-id', + clientVersion: '1.0.0', + showApprovalRequest: (): undefined => undefined, + clientConfigApiService: new ClientConfigApiService({ + fetch: globalThis.fetch, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }), + getMetaMetricsId: (): string => 'fake-metrics-id', + }; + + it('exposes controllerMetadata for each initialized controller', () => { + wallet = new Wallet(options); + + const names = Object.keys(wallet.controllerMetadata); + expect(names).toStrictEqual(Object.keys(wallet.state)); + for (const name of names) { + expect(wallet.controllerMetadata[name]).toBeDefined(); + } + }); + + it('omits instances without a metadata property from controllerMetadata', () => { + const fakeMetadata = { + foo: { persist: true, includeInDebugSnapshot: false }, + }; + jest.spyOn(initializationModule, 'initialize').mockReturnValueOnce({ + WithMeta: { state: {}, metadata: fakeMetadata }, + NoMeta: { state: {} }, + } as never); + + wallet = new Wallet(options); + + expect(wallet.controllerMetadata).toStrictEqual({ + WithMeta: fakeMetadata, + }); + expect(Object.keys(wallet.state)).toStrictEqual(['WithMeta', 'NoMeta']); + }); + + it('publishes Wallet:destroyed exactly once on destroy', async () => { + wallet = new Wallet(options); + + const listener = jest.fn(); + wallet.messenger.subscribe('Wallet:destroyed', listener); + + await wallet.destroy(); + await wallet.destroy(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('publishes Wallet:destroyed even if a controller destroy throws synchronously', async () => { + wallet = new Wallet(options); + + jest + .spyOn(TransactionController.prototype, 'destroy') + .mockImplementation(() => { + throw new Error('sync destroy error'); + }); + + const listener = jest.fn(); + wallet.messenger.subscribe('Wallet:destroyed', listener); + + await wallet.destroy(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('publishes Wallet:destroyed even if a controller destroy rejects', async () => { + wallet = new Wallet(options); + + jest + .spyOn(TransactionController.prototype, 'destroy') + .mockRejectedValue(new Error('async destroy error') as never); + + const listener = jest.fn(); + wallet.messenger.subscribe('Wallet:destroyed', listener); + + await wallet.destroy(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/wallet/src/Wallet.ts b/packages/wallet/src/Wallet.ts new file mode 100644 index 00000000000..41c6ecf3a2d --- /dev/null +++ b/packages/wallet/src/Wallet.ts @@ -0,0 +1,81 @@ +import type { StateMetadataConstraint } from '@metamask/base-controller'; +import { Messenger } from '@metamask/messenger'; +import { hasProperty } from '@metamask/utils'; + +import type { + DefaultActions, + DefaultEvents, + DefaultInstances, + DefaultState, + RootMessenger, +} from './initialization'; +import { initialize } from './initialization'; +import type { WalletOptions } from './types'; + +export class Wallet { + // TODO: Expand types when passing additionalConfigurations. + public readonly messenger: RootMessenger; + + readonly #instances: DefaultInstances; + + readonly #controllerMetadata: Readonly< + Record> + >; + + #destroyed = false; + + constructor({ state, ...options }: WalletOptions) { + this.messenger = new Messenger({ + namespace: 'Wallet', + }); + + this.#instances = initialize({ + state: state ?? {}, + messenger: this.messenger, + options, + }); + + this.#controllerMetadata = Object.fromEntries( + Object.entries(this.#instances) + .filter(([_, instance]) => hasProperty(instance, 'metadata')) + .map(([name, instance]) => [name, instance.metadata]), + ); + } + + get state(): DefaultState { + return Object.entries(this.#instances).reduce>( + (totalState, [name, instance]) => { + totalState[name] = instance.state ?? null; + return totalState; + }, + {}, + ) as DefaultState; + } + + get controllerMetadata(): Readonly< + Record> + > { + return this.#controllerMetadata; + } + + async destroy(): Promise { + if (this.#destroyed) { + return; + } + this.#destroyed = true; + + await Promise.allSettled( + Object.values(this.#instances).map(async (instance) => { + // @ts-expect-error Accessing protected property. + if (typeof instance.destroy === 'function') { + // @ts-expect-error Accessing protected property. + return await instance.destroy(); + } + /* istanbul ignore next */ + return undefined; + }), + ); + + this.messenger.publish('Wallet:destroyed'); + } +} diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts new file mode 100644 index 00000000000..5fa0502ec2b --- /dev/null +++ b/packages/wallet/src/index.ts @@ -0,0 +1,8 @@ +export { Wallet } from './Wallet'; +export type { WalletOptions } from './types'; +export type { + DefaultActions, + DefaultEvents, + RootMessenger, + WalletDestroyedEvent, +} from './initialization'; diff --git a/packages/wallet/src/initialization/defaults.ts b/packages/wallet/src/initialization/defaults.ts new file mode 100644 index 00000000000..7156aad7a0b --- /dev/null +++ b/packages/wallet/src/initialization/defaults.ts @@ -0,0 +1,57 @@ +import type { + ActionConstraint, + EventConstraint, + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import * as defaultConfigurations from './instances'; +import type { InitializationConfiguration, InstanceState } from './types'; + +export { defaultConfigurations }; + +type ExtractInstance = + Config extends InitializationConfiguration + ? Instance + : never; + +type ExtractInstanceMessenger = + Config extends InitializationConfiguration + ? InferredMessenger + : never; + +type ExtractName = + ExtractInstance extends { name: infer Name extends string } + ? Name + : never; + +type Configs = typeof defaultConfigurations; + +type AllMessengers = ExtractInstanceMessenger; + +export type DefaultInstances = { + [Key in keyof Configs as ExtractName]: ExtractInstance< + Configs[Key] + >; +}; + +export type DefaultActions = MessengerActions; + +export type WalletDestroyedEvent = { + type: 'Wallet:destroyed'; + payload: []; +}; + +export type DefaultEvents = + | MessengerEvents + | WalletDestroyedEvent; + +export type RootMessenger< + AllowedActions extends ActionConstraint = ActionConstraint, + AllowedEvents extends EventConstraint = EventConstraint, +> = Messenger<'Wallet', AllowedActions, AllowedEvents>; + +export type DefaultState = { + [Key in keyof DefaultInstances]: InstanceState; +}; diff --git a/packages/wallet/src/initialization/index.ts b/packages/wallet/src/initialization/index.ts new file mode 100644 index 00000000000..757f51fc01d --- /dev/null +++ b/packages/wallet/src/initialization/index.ts @@ -0,0 +1,9 @@ +export type { + DefaultActions, + DefaultEvents, + DefaultInstances, + DefaultState, + RootMessenger, + WalletDestroyedEvent, +} from './defaults'; +export { initialize } from './initialization'; diff --git a/packages/wallet/src/initialization/initialization.ts b/packages/wallet/src/initialization/initialization.ts new file mode 100644 index 00000000000..2a977c52073 --- /dev/null +++ b/packages/wallet/src/initialization/initialization.ts @@ -0,0 +1,53 @@ +import { Json } from '@metamask/utils'; + +import { WalletOptions } from '../types'; +import type { DefaultInstances } from './defaults'; +import { defaultConfigurations, RootMessenger } from './defaults'; +import { InitializationConfiguration } from './types'; + +export type InitializeArgs = { + state: Record; + messenger: RootMessenger; + initializationConfigurations?: InitializationConfiguration< + unknown, + unknown + >[]; + options: WalletOptions; +}; + +export function initialize({ + state, + messenger, + initializationConfigurations = [], + options, +}: InitializeArgs): DefaultInstances { + const overriddenConfiguration = initializationConfigurations.map( + (config) => config.name, + ); + + const configurationEntries = initializationConfigurations.concat( + Object.values(defaultConfigurations).filter( + (config) => !overriddenConfiguration.includes(config.name), + ), + ); + + const instances: Record = {}; + + for (const config of configurationEntries) { + const { name } = config; + + const instanceState = state[name]; + + const instanceMessenger = config.messenger(messenger); + + const { instance } = config.init({ + state: instanceState, + messenger: instanceMessenger, + options, + }); + + instances[name] = instance as Record; + } + + return instances as DefaultInstances; +} diff --git a/packages/wallet/src/initialization/instances/accounts-controller.ts b/packages/wallet/src/initialization/instances/accounts-controller.ts new file mode 100644 index 00000000000..405caf03d6e --- /dev/null +++ b/packages/wallet/src/initialization/instances/accounts-controller.ts @@ -0,0 +1,60 @@ +import { + AccountsController, + AccountsControllerMessenger, +} from '@metamask/accounts-controller'; +import { + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import { InitializationConfiguration } from '../types'; + +type AllowedActions = MessengerActions; + +type AllowedEvents = MessengerEvents; + +export const accountsController: InitializationConfiguration< + AccountsController, + AccountsControllerMessenger +> = { + name: 'AccountsController', + init: ({ state, messenger }) => { + const instance = new AccountsController({ + state, + messenger, + }); + + return { + instance, + }; + }, + messenger: (parent) => { + const accountsControllerMessenger = new Messenger< + 'AccountsController', + AllowedActions, + AllowedEvents, + typeof parent + >({ + namespace: 'AccountsController', + parent, + }); + + parent.delegate({ + messenger: accountsControllerMessenger, + actions: [ + 'KeyringController:getState', + 'KeyringController:getKeyringsByType', + ], + events: [ + 'KeyringController:stateChange', + 'SnapKeyring:accountAssetListUpdated', + 'SnapKeyring:accountBalancesUpdated', + 'SnapKeyring:accountTransactionsUpdated', + 'MultichainNetworkController:networkDidChange', + ], + }); + + return accountsControllerMessenger; + }, +}; diff --git a/packages/wallet/src/initialization/instances/approval-controller.ts b/packages/wallet/src/initialization/instances/approval-controller.ts new file mode 100644 index 00000000000..1e7b7b24ba2 --- /dev/null +++ b/packages/wallet/src/initialization/instances/approval-controller.ts @@ -0,0 +1,46 @@ +import { + ApprovalController, + ApprovalControllerMessenger, +} from '@metamask/approval-controller'; +import { ApprovalType } from '@metamask/controller-utils'; +import { Messenger } from '@metamask/messenger'; + +import { InitializationConfiguration } from '../types'; + +export const approvalController: InitializationConfiguration< + ApprovalController, + ApprovalControllerMessenger +> = { + name: 'ApprovalController', + init: ({ state, messenger, options }) => { + const instance = new ApprovalController({ + state, + messenger, + showApprovalRequest: options.showApprovalRequest, + typesExcludedFromRateLimiting: [ + ApprovalType.PersonalSign, + ApprovalType.EthSignTypedData, + ApprovalType.Transaction, + ApprovalType.WatchAsset, + ApprovalType.EthGetEncryptionPublicKey, + ApprovalType.EthDecrypt, + + // Exclude Smart TX Status Page from rate limiting to allow sequential + // transactions. + 'smartTransaction:showSmartTransactionStatusPage', + + // Allow one flavor of snap_dialog to be queued. + 'snap_dialog', + ], + }); + + return { + instance, + }; + }, + messenger: (parent) => + new Messenger<'ApprovalController', never, never, typeof parent>({ + namespace: 'ApprovalController', + parent, + }), +}; diff --git a/packages/wallet/src/initialization/instances/connectivity-controller.ts b/packages/wallet/src/initialization/instances/connectivity-controller.ts new file mode 100644 index 00000000000..98abc2c228c --- /dev/null +++ b/packages/wallet/src/initialization/instances/connectivity-controller.ts @@ -0,0 +1,47 @@ +import { + CONNECTIVITY_STATUSES, + ConnectivityAdapter, + ConnectivityController, + ConnectivityControllerMessenger, + ConnectivityStatus, +} from '@metamask/connectivity-controller'; +import { Messenger } from '@metamask/messenger'; + +import { InitializationConfiguration } from '../types'; + +// TODO: For now, we assume we are always online. +class AlwaysOnlineAdapter implements ConnectivityAdapter { + async getStatus(): Promise { + return CONNECTIVITY_STATUSES.Online; + } + + onConnectivityChange(_callback: (status: ConnectivityStatus) => void): void { + // no-op + } + + destroy(): void { + // no-op + } +} + +export const connectivityController: InitializationConfiguration< + ConnectivityController, + ConnectivityControllerMessenger +> = { + name: 'ConnectivityController', + init: ({ messenger }) => { + const instance = new ConnectivityController({ + messenger, + connectivityAdapter: new AlwaysOnlineAdapter(), + }); + + return { + instance, + }; + }, + messenger: (parent) => + new Messenger<'ConnectivityController', never, never, typeof parent>({ + namespace: 'ConnectivityController', + parent, + }), +}; diff --git a/packages/wallet/src/initialization/instances/index.ts b/packages/wallet/src/initialization/instances/index.ts new file mode 100644 index 00000000000..4f869053f11 --- /dev/null +++ b/packages/wallet/src/initialization/instances/index.ts @@ -0,0 +1,7 @@ +export * from './accounts-controller'; +export * from './approval-controller'; +export * from './connectivity-controller'; +export * from './keyring-controller'; +export * from './network-controller'; +export * from './remote-feature-flag-controller'; +export * from './transaction-controller'; diff --git a/packages/wallet/src/initialization/instances/keyring-controller.ts b/packages/wallet/src/initialization/instances/keyring-controller.ts new file mode 100644 index 00000000000..7f3a356864c --- /dev/null +++ b/packages/wallet/src/initialization/instances/keyring-controller.ts @@ -0,0 +1,162 @@ +import type { + DetailedEncryptionResult, + EncryptionKey, + KeyDerivationOptions, +} from '@metamask/browser-passworder'; +import { + encrypt, + encryptWithDetail, + encryptWithKey, + decrypt, + decryptWithDetail, + decryptWithKey, + isVaultUpdated, + keyFromPassword, + importKey, + exportKey, + generateSalt, +} from '@metamask/browser-passworder'; +import type { Encryptor } from '@metamask/keyring-controller'; +import { + KeyringController, + KeyringControllerMessenger, +} from '@metamask/keyring-controller'; +import { Messenger } from '@metamask/messenger'; + +import { InitializationConfiguration } from '../types'; + +/** + * A factory function for the encrypt method of the browser-passworder library, + * that encrypts with a given number of iterations. + * + * @param iterations - The number of iterations to use for the PBKDF2 algorithm. + * @returns A function that encrypts with the given number of iterations. + */ +const encryptFactory = + (iterations: number) => + async ( + password: string, + data: unknown, + key?: EncryptionKey | CryptoKey, + salt?: string, + ): Promise => + encrypt(password, data, key, salt, { + algorithm: 'PBKDF2', + params: { + iterations, + }, + }); + +/** + * A factory function for the encryptWithDetail method of the browser-passworder library, + * that encrypts with a given number of iterations. + * + * @param iterations - The number of iterations to use for the PBKDF2 algorithm. + * @returns A function that encrypts with the given number of iterations. + */ +const encryptWithDetailFactory = + (iterations: number) => + async ( + password: string, + object: unknown, + salt?: string, + ): Promise => + encryptWithDetail(password, object, salt, { + algorithm: 'PBKDF2', + params: { + iterations, + }, + }); + +/** + * A factory function for the keyFromPassword method of the browser-passworder library, + * that generates a key from a password and a salt. + * + * This factory function overrides the default key derivation options with the specified + * number of iterations, unless existing key derivation options are passed in. + * + * @param iterations - The number of iterations to use for the PBKDF2 algorithm. + * @returns A function that generates a key with a potentially overriden number of iterations. + */ +const keyFromPasswordFactory = + (iterations: number) => + async ( + password: string, + salt: string, + exportable?: boolean, + opts?: KeyDerivationOptions, + ): Promise => + keyFromPassword( + password, + salt, + exportable, + opts ?? { + algorithm: 'PBKDF2', + params: { + iterations, + }, + }, + ); + +/** + * A factory function for the isVaultUpdated method of the browser-passworder library, + * that checks if the given vault was encrypted with the given number of iterations. + * + * @param iterations - The number of iterations to use for the PBKDF2 algorithm. + * @returns A function that checks if the vault was encrypted with the given number of iterations. + */ +const isVaultUpdatedFactory = + (iterations: number) => + (vault: string): boolean => + isVaultUpdated(vault, { + algorithm: 'PBKDF2', + params: { + iterations, + }, + }); + +/** + * A factory function that returns an encryptor with the given number of iterations. + * + * The returned encryptor is a wrapper around the browser-passworder library, that + * calls the encrypt and encryptWithDetail methods with the given number of iterations. + * + * @param iterations - The number of iterations to use for the PBKDF2 algorithm. + * @returns An encryptor set with the given number of iterations. + */ +const encryptorFactory = (iterations: number): Encryptor => ({ + encrypt: encryptFactory(iterations), + encryptWithKey, + encryptWithDetail: encryptWithDetailFactory(iterations), + decrypt, + decryptWithKey, + decryptWithDetail, + keyFromPassword: keyFromPasswordFactory(iterations), + isVaultUpdated: isVaultUpdatedFactory(iterations), + importKey, + exportKey, + generateSalt, +}); + +export const keyringController: InitializationConfiguration< + KeyringController, + KeyringControllerMessenger +> = { + name: 'KeyringController', + init: ({ state, messenger }) => { + const instance = new KeyringController({ + state, + messenger, + encryptor: encryptorFactory(600_000), + }); + + return { + instance, + }; + }, + messenger: (parent) => + new Messenger<'KeyringController', never, never, typeof parent>({ + namespace: 'KeyringController', + parent, + }), +}; diff --git a/packages/wallet/src/initialization/instances/network-controller.ts b/packages/wallet/src/initialization/instances/network-controller.ts new file mode 100644 index 00000000000..aa23185d83c --- /dev/null +++ b/packages/wallet/src/initialization/instances/network-controller.ts @@ -0,0 +1,92 @@ +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; +import { DEFAULT_MAX_RETRIES } from '@metamask/controller-utils'; +import { + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { NetworkControllerOptions } from '@metamask/network-controller'; +import { + NetworkController, + NetworkControllerMessenger, +} from '@metamask/network-controller'; +import { Duration, inMilliseconds } from '@metamask/utils'; + +import { InitializationConfiguration } from '../types'; + +type AllowedActions = MessengerActions; + +type AllowedEvents = MessengerEvents; + +export const networkController: InitializationConfiguration< + NetworkController, + NetworkControllerMessenger +> = { + name: 'NetworkController', + init: ({ state, messenger, options }) => { + // TODO: This was gutted to simplify implementation for now. + const getRpcServiceOptions: NetworkControllerOptions['getRpcServiceOptions'] = + () => { + const maxRetries = DEFAULT_MAX_RETRIES; + + const isOffline = (): boolean => { + const connectivityState = messenger.call( + 'ConnectivityController:getState', + ); + return ( + connectivityState.connectivityStatus === + CONNECTIVITY_STATUSES.Offline + ); + }; + + return { + fetch: globalThis.fetch.bind(globalThis), + btoa: globalThis.btoa.bind(globalThis), + isOffline, + policyOptions: { + // Ensure that the "cooldown" period after breaking the circuit is short. + circuitBreakDuration: inMilliseconds(30, Duration.Second), + maxRetries, + // Ensure that if the endpoint continually responds with errors, we + // break the circuit relatively fast (but not prematurely). + // + // Note that the circuit will break much faster if the errors are + // retriable (e.g. 503) than if not (e.g. 500), so we attempt to strike + // a balance here. + maxConsecutiveFailures: (maxRetries + 1) * 3, + }, + }; + }; + + // TODO: Add the rest of the arguments. + const instance = new NetworkController({ + state, + messenger, + getRpcServiceOptions, + infuraProjectId: options.infuraProjectId, + }); + + return { + instance, + }; + }, + messenger: (parent) => { + const networkControllerMessenger = new Messenger< + 'NetworkController', + AllowedActions, + AllowedEvents, + typeof parent + >({ + namespace: 'NetworkController', + parent, + }); + + parent.delegate({ + messenger: networkControllerMessenger, + actions: ['ConnectivityController:getState'], + events: [], + }); + + return networkControllerMessenger; + }, +}; diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller.ts new file mode 100644 index 00000000000..4b5aaf3ca76 --- /dev/null +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller.ts @@ -0,0 +1,33 @@ +import { Messenger } from '@metamask/messenger'; +import { + RemoteFeatureFlagController, + RemoteFeatureFlagControllerMessenger, +} from '@metamask/remote-feature-flag-controller'; + +import { InitializationConfiguration } from '../types'; + +export const remoteFeatureFlagController: InitializationConfiguration< + RemoteFeatureFlagController, + RemoteFeatureFlagControllerMessenger +> = { + name: 'RemoteFeatureFlagController', + init: ({ state, messenger, options }) => { + // TODO: Add the rest of the arguments. + const instance = new RemoteFeatureFlagController({ + state, + messenger, + clientVersion: options.clientVersion, + clientConfigApiService: options.clientConfigApiService, + getMetaMetricsId: options.getMetaMetricsId, + }); + + return { + instance, + }; + }, + messenger: (parent) => + new Messenger<'RemoteFeatureFlagController', never, never, typeof parent>({ + namespace: 'RemoteFeatureFlagController', + parent, + }), +}; diff --git a/packages/wallet/src/initialization/instances/transaction-controller.ts b/packages/wallet/src/initialization/instances/transaction-controller.ts new file mode 100644 index 00000000000..3768e5d195c --- /dev/null +++ b/packages/wallet/src/initialization/instances/transaction-controller.ts @@ -0,0 +1,118 @@ +import type { KeyringControllerSignTransactionAction } from '@metamask/keyring-controller'; +import { + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { + NetworkControllerGetEIP1559CompatibilityAction, + NetworkControllerGetNetworkClientRegistryAction, + NetworkControllerGetStateAction, +} from '@metamask/network-controller'; +import type { TransactionControllerOptions } from '@metamask/transaction-controller'; +import { + TransactionController, + TransactionControllerMessenger, +} from '@metamask/transaction-controller'; + +import { bindMessengerAction, InitializationConfiguration } from '../types'; + +type InitActions = + | NetworkControllerGetNetworkClientRegistryAction + | NetworkControllerGetEIP1559CompatibilityAction + | NetworkControllerGetStateAction + | KeyringControllerSignTransactionAction; + +type AllowedActions = + | MessengerActions + | InitActions; + +type AllowedEvents = MessengerEvents; + +type WalletTransactionControllerMessenger = Messenger< + 'TransactionController', + AllowedActions, + AllowedEvents +>; + +export const transactionController: InitializationConfiguration< + TransactionController, + WalletTransactionControllerMessenger +> = { + name: 'TransactionController', + init: ({ state, messenger }) => { + // TODO: Add the rest of the arguments. + const instance = new TransactionController({ + state, + messenger: messenger as unknown as TransactionControllerMessenger, + disableHistory: true, + disableSendFlowHistory: true, + disableSwaps: false, + hooks: {}, + getNetworkClientRegistry: bindMessengerAction( + messenger, + 'NetworkController:getNetworkClientRegistry', + ), + getCurrentNetworkEIP1559Compatibility: bindMessengerAction( + messenger, + 'NetworkController:getEIP1559Compatibility', + ) as () => Promise, + getNetworkState: bindMessengerAction( + messenger, + 'NetworkController:getState', + ), + // KeyringController.signTransaction is typed as returning + // Promise (a plain data object), but the actual keyring + // implementations return the full TypedTransaction class instance. + // TransactionController expects Promise here. The + // cast bridges a stale return-type declaration in KeyringController, + // not a real runtime mismatch. + sign: bindMessengerAction( + messenger, + 'KeyringController:signTransaction', + ) as unknown as TransactionControllerOptions['sign'], + }); + + return { + instance, + }; + }, + messenger: (parent) => { + const transactionControllerMessenger = new Messenger< + 'TransactionController', + AllowedActions, + AllowedEvents, + typeof parent + >({ + namespace: 'TransactionController', + parent, + }); + + parent.delegate({ + messenger: transactionControllerMessenger, + actions: [ + 'AccountsController:getSelectedAccount', + 'AccountsController:getState', + `ApprovalController:addRequest`, + 'KeyringController:signEip7702Authorization', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + 'RemoteFeatureFlagController:getState', + // TODO: These are added for use in the constructor, in the extension this uses the "init messenger" concept. + 'NetworkController:getNetworkClientRegistry', + 'NetworkController:getEIP1559Compatibility', + 'NetworkController:getState', + 'KeyringController:signTransaction', + ], + events: [ + 'AccountActivityService:transactionUpdated', + 'AccountActivityService:statusChanged', + 'AccountsController:selectedAccountChange', + 'BackendWebSocketService:connectionStateChanged', + 'NetworkController:stateChange', + ], + }); + + return transactionControllerMessenger; + }, +}; diff --git a/packages/wallet/src/initialization/types.ts b/packages/wallet/src/initialization/types.ts new file mode 100644 index 00000000000..b8c1af09541 --- /dev/null +++ b/packages/wallet/src/initialization/types.ts @@ -0,0 +1,58 @@ +import type { + ActionConstraint, + EventConstraint, + ExtractActionParameters, + ExtractActionResponse, + Messenger, + MessengerActions, +} from '@metamask/messenger'; + +import type { WalletOptions } from '../types'; +import type { RootMessenger } from './defaults'; + +export type InstanceState = Instance extends { state: unknown } + ? Instance['state'] + : unknown; + +export type InitFunctionArguments = { + state: InstanceState; + messenger: InstanceMessenger; + options: WalletOptions; +}; + +/** + * Typed wrapper around `messenger.call.bind(messenger, actionType)`. + * + * TypeScript's `Function.prototype.bind` loses generic inference on + * `Messenger.call`, so the bound function's parameters and return type + * collapse to a union of every action. This helper restores the correct + * per-action types via an explicit cast that is safe because `bind` + * preserves the runtime behavior exactly. + * + * @param messenger - The messenger instance. + * @param actionType - The action to bind. + * @returns A function that calls the action with the correct types. + */ +export function bindMessengerAction< + Msgr extends Messenger, + ActionType extends MessengerActions['type'], +>( + messenger: Msgr, + actionType: ActionType, +): ( + ...args: ExtractActionParameters, ActionType> +) => ExtractActionResponse, ActionType> { + return messenger.call.bind(messenger, actionType) as ( + ...args: ExtractActionParameters, ActionType> + ) => ExtractActionResponse, ActionType>; +} + +export type InitializationConfiguration = { + name: string; + // This is a method as opposed to function property in order to collect + // heterogeneous InitializationConfiguration values in a single array. + init(args: InitFunctionArguments): { + instance: Instance; + }; + messenger(parent: RootMessenger): InstanceMessenger; +}; diff --git a/packages/wallet/src/persistence/KeyValueStore.test.ts b/packages/wallet/src/persistence/KeyValueStore.test.ts new file mode 100644 index 00000000000..f7c3ad6445d --- /dev/null +++ b/packages/wallet/src/persistence/KeyValueStore.test.ts @@ -0,0 +1,117 @@ +import type { Json } from '@metamask/utils'; +import Sqlite from 'better-sqlite3'; +import { unlink } from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { KeyValueStore } from './KeyValueStore'; + +describe('KeyValueStore', () => { + let store: KeyValueStore; + + beforeEach(() => { + store = new KeyValueStore(':memory:'); + }); + + afterEach(() => { + store.close(); + }); + + describe('set and get', () => { + it('stores and retrieves a string value', () => { + store.set('key1', 'hello'); + expect(store.get('key1')).toBe('hello'); + }); + + it('stores and retrieves a number value', () => { + store.set('key1', 42); + expect(store.get('key1')).toBe(42); + }); + + it('stores and retrieves a boolean value', () => { + store.set('key1', true); + expect(store.get('key1')).toBe(true); + }); + + it('stores and retrieves null', () => { + store.set('key1', null); + expect(store.get('key1')).toBeNull(); + }); + + it('stores and retrieves a complex object', () => { + const makeValue = (): Json => ({ + nested: { array: [1, 'two', null, { deep: true }] }, + }); + store.set('key1', makeValue()); + expect(store.get('key1')).toStrictEqual(makeValue()); + }); + + it('returns undefined for a nonexistent key', () => { + expect(store.get('missing')).toBeUndefined(); + }); + + it('overwrites an existing key', () => { + store.set('key1', 'first'); + store.set('key1', 'second'); + expect(store.get('key1')).toBe('second'); + }); + }); + + describe('getAll', () => { + it('returns an empty object when the store is empty', () => { + expect(store.getAll()).toStrictEqual({}); + }); + + it('returns all stored key-value pairs', () => { + store.set('a', 1); + store.set('b', 'two'); + store.set('c', [3]); + expect(store.getAll()).toStrictEqual({ a: 1, b: 'two', c: [3] }); + }); + }); + + describe('delete', () => { + it('removes an existing key', () => { + store.set('key1', 'value'); + store.delete('key1'); + expect(store.get('key1')).toBeUndefined(); + }); + + it('does nothing when deleting a nonexistent key', () => { + expect(() => store.delete('missing')).not.toThrow(); + }); + }); + + describe('corrupt data', () => { + let tempPath: string; + let corruptStore: KeyValueStore; + + beforeEach(() => { + tempPath = path.join(os.tmpdir(), `kv-test-${Date.now()}.db`); + corruptStore = new KeyValueStore(tempPath); + + const rawDb = new Sqlite(tempPath); + rawDb + .prepare('INSERT INTO kv (key, value) VALUES (?, ?)') + .run('bad', 'not json'); + rawDb.close(); + }); + + afterEach(async () => { + corruptStore.close(); + await unlink(tempPath); + }); + + it('throws when get() encounters a non-JSON value', () => { + expect(() => corruptStore.get('bad')).toThrow( + "Failed to parse stored value for key 'bad'", + ); + }); + + it('throws when getAll() encounters a non-JSON value', () => { + expect(() => corruptStore.getAll()).toThrow( + "Failed to parse stored value for key 'bad'", + ); + }); + }); +}); diff --git a/packages/wallet/src/persistence/KeyValueStore.ts b/packages/wallet/src/persistence/KeyValueStore.ts new file mode 100644 index 00000000000..b7b0d654596 --- /dev/null +++ b/packages/wallet/src/persistence/KeyValueStore.ts @@ -0,0 +1,73 @@ +import type { Json } from '@metamask/utils'; +import Sqlite from 'better-sqlite3'; + +/** + * A synchronous key-value store backed by better-sqlite3. + * + * Uses a single `kv` table with TEXT key (primary key) and TEXT value + * (JSON-serialized). Intended as the persistence backend for wallet + * controller state. + */ +export class KeyValueStore { + readonly #db: Sqlite.Database; + + readonly #getStmt: Sqlite.Statement<[string], { value: string } | undefined>; + + readonly #setStmt: Sqlite.Statement<[string, string], void>; + + readonly #deleteStmt: Sqlite.Statement<[string], void>; + + readonly #getAllStmt: Sqlite.Statement<[], { key: string; value: string }>; + + constructor(databasePath: string) { + this.#db = new Sqlite(databasePath); + this.#db.pragma('journal_mode = WAL'); + this.#db.exec( + 'CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)', + ); + + this.#getStmt = this.#db.prepare('SELECT value FROM kv WHERE key = ?'); + this.#setStmt = this.#db.prepare( + 'INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)', + ); + this.#deleteStmt = this.#db.prepare('DELETE FROM kv WHERE key = ?'); + this.#getAllStmt = this.#db.prepare('SELECT key, value FROM kv'); + } + + get(key: string): Json | undefined { + const row = this.#getStmt.get(key); + if (!row) { + return undefined; + } + try { + return JSON.parse(row.value); + } catch { + throw new Error(`Failed to parse stored value for key '${key}'`); + } + } + + set(key: string, value: Json): void { + this.#setStmt.run(key, JSON.stringify(value)); + } + + getAll(): Record { + const rows = this.#getAllStmt.all(); + const result: Record = {}; + for (const row of rows) { + try { + result[row.key] = JSON.parse(row.value); + } catch { + throw new Error(`Failed to parse stored value for key '${row.key}'`); + } + } + return result; + } + + delete(key: string): void { + this.#deleteStmt.run(key); + } + + close(): void { + this.#db.close(); + } +} diff --git a/packages/wallet/src/persistence/index.ts b/packages/wallet/src/persistence/index.ts new file mode 100644 index 00000000000..08081afc4b3 --- /dev/null +++ b/packages/wallet/src/persistence/index.ts @@ -0,0 +1,2 @@ +export { KeyValueStore } from './KeyValueStore'; +export { loadState, subscribeToChanges } from './persistence'; diff --git a/packages/wallet/src/persistence/persistence.test.ts b/packages/wallet/src/persistence/persistence.test.ts new file mode 100644 index 00000000000..ceee3471531 --- /dev/null +++ b/packages/wallet/src/persistence/persistence.test.ts @@ -0,0 +1,479 @@ +import type { StateMetadataConstraint } from '@metamask/base-controller'; +import type { Json } from '@metamask/utils'; + +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../initialization'; +import { KeyValueStore } from './KeyValueStore'; +import { loadState, subscribeToChanges } from './persistence'; + +type TestMessenger = RootMessenger; + +describe('loadState', () => { + let store: KeyValueStore; + + beforeEach(() => { + store = new KeyValueStore(':memory:'); + }); + + afterEach(() => { + store.close(); + }); + + it('returns an empty object when the store is empty', () => { + expect(loadState(store)).toStrictEqual({}); + }); + + it('groups keys by controller name', () => { + store.set('ControllerA.prop1', 'value1'); + store.set('ControllerA.prop2', 42); + store.set('ControllerB.prop1', [1, 2, 3]); + + expect(loadState(store)).toStrictEqual({ + ControllerA: { prop1: 'value1', prop2: 42 }, + ControllerB: { prop1: [1, 2, 3] }, + }); + }); + + it('splits on the first dot only', () => { + store.set('Controller.prop.with.dots', 'value'); + + expect(loadState(store)).toStrictEqual({ + Controller: { 'prop.with.dots': 'value' }, + }); + }); + + it('throws on a key without a dot separator', () => { + store.set('noDot', 'value'); + + expect(() => loadState(store)).toThrow( + "Invalid key in store: 'noDot'. Expected format 'ControllerName.propertyName'.", + ); + }); + + it('throws on a key with an empty controller name', () => { + store.set('.propName', 'value'); + + expect(() => loadState(store)).toThrow( + "Invalid key in store: '.propName'. Both controller name and property name must be non-empty.", + ); + }); + + it('throws on a key with an empty property name', () => { + store.set('ControllerName.', 'value'); + + expect(() => loadState(store)).toThrow( + "Invalid key in store: 'ControllerName.'. Both controller name and property name must be non-empty.", + ); + }); +}); + +describe('subscribeToChanges', () => { + let store: KeyValueStore; + + beforeEach(() => { + store = new KeyValueStore(':memory:'); + }); + + afterEach(() => { + store.close(); + }); + + it('writes persist-flagged properties on state change', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([ + ['persisted', true], + ['transient', false], + ]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + publishStateChanged(messenger, 'TestController', { + state: { persisted: 'savedValue', transient: 'notSaved' }, + patches: [ + { op: 'replace', path: ['persisted'], value: 'savedValue' }, + { op: 'replace', path: ['transient'], value: 'notSaved' }, + ], + }); + + expect(store.get('TestController.persisted')).toBe('savedValue'); + expect(store.get('TestController.transient')).toBeUndefined(); + }); + + it('only writes properties that are in the patches', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([ + ['propA', true], + ['propB', true], + ]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + publishStateChanged(messenger, 'TestController', { + state: { propA: 'changedA', propB: 'unchangedB' }, + patches: [{ op: 'replace', path: ['propA'], value: 'changedA' }], + }); + + expect(store.get('TestController.propA')).toBe('changedA'); + expect(store.get('TestController.propB')).toBeUndefined(); + }); + + it('applies StateDeriver functions before writing', () => { + const deriver = (value: never): Json => + (value as unknown as string).toUpperCase(); + + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['derived', deriver]]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + publishStateChanged(messenger, 'TestController', { + state: { derived: 'hello' }, + patches: [{ op: 'replace', path: ['derived'], value: 'hello' }], + }); + + expect(store.get('TestController.derived')).toBe('HELLO'); + }); + + it('handles nested property changes by extracting the top-level key', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['nested', true]]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + publishStateChanged(messenger, 'TestController', { + state: { nested: { inner: { deep: 'value' } } }, + patches: [ + { op: 'replace', path: ['nested', 'inner', 'deep'], value: 'value' }, + ], + }); + + expect(store.get('TestController.nested')).toStrictEqual({ + inner: { deep: 'value' }, + }); + }); + + it('skips controllers with no persisted properties', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['transientOnly', false]]), + }); + + const unsubscribe = subscribeToChanges( + messenger, + controllerMetadata, + store, + ); + + publishStateChanged(messenger, 'TestController', { + state: { transientOnly: 'value' }, + patches: [{ op: 'replace', path: ['transientOnly'], value: 'value' }], + }); + + expect(store.getAll()).toStrictEqual({}); + unsubscribe(); + }); + + it('returns an unsubscribe function that stops persistence', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['prop', true]]), + }); + + const unsubscribe = subscribeToChanges( + messenger, + controllerMetadata, + store, + ); + + publishStateChanged(messenger, 'TestController', { + state: { prop: 'first' }, + patches: [{ op: 'replace', path: ['prop'], value: 'first' }], + }); + + expect(store.get('TestController.prop')).toBe('first'); + + unsubscribe(); + + publishStateChanged(messenger, 'TestController', { + state: { prop: 'second' }, + patches: [{ op: 'replace', path: ['prop'], value: 'second' }], + }); + + expect(store.get('TestController.prop')).toBe('first'); + }); + + it('deletes persisted property when it is removed from state', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['removable', true]]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + // First, persist a value + publishStateChanged(messenger, 'TestController', { + state: { removable: 'exists' }, + patches: [{ op: 'replace', path: ['removable'], value: 'exists' }], + }); + + expect(store.get('TestController.removable')).toBe('exists'); + + // Now remove it — state no longer contains the property + publishStateChanged(messenger, 'TestController', { + state: {}, + patches: [{ op: 'remove', path: ['removable'] }], + }); + + expect(store.get('TestController.removable')).toBeUndefined(); + }); + + it('persists all flagged properties on root state replacement', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([ + ['propA', true], + ['propB', true], + ['transient', false], + ]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + publishStateChanged(messenger, 'TestController', { + state: { propA: 'newA', propB: 'newB', transient: 'skip' }, + patches: [ + { + op: 'replace', + path: [], + value: { propA: 'newA', propB: 'newB', transient: 'skip' }, + }, + ], + }); + + expect(store.get('TestController.propA')).toBe('newA'); + expect(store.get('TestController.propB')).toBe('newB'); + expect(store.get('TestController.transient')).toBeUndefined(); + }); + + it('logs and continues when store.set throws', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([ + ['propA', true], + ['propB', true], + ]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + const error = new Error('disk full'); + const originalSet = store.set.bind(store); + let callCount = 0; + jest.spyOn(store, 'set').mockImplementation((key, value) => { + callCount += 1; + if (callCount === 1) { + throw error; + } + originalSet(key, value); + }); + + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + publishStateChanged(messenger, 'TestController', { + state: { propA: 'a', propB: 'b' }, + patches: [ + { op: 'replace', path: ['propA'], value: 'a' }, + { op: 'replace', path: ['propB'], value: 'b' }, + ], + }); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to persist state for TestController.propA', + error, + ); + // propB should still be persisted despite propA failing + expect(store.get('TestController.propB')).toBe('b'); + + consoleSpy.mockRestore(); + }); + + it('handles multiple controllers independently', () => { + const { messenger, controllerMetadata } = createMockControllers({ + ControllerA: createStateMetadata([['data', true]]), + ControllerB: createStateMetadata([['data', true]]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + publishStateChanged(messenger, 'ControllerA', { + state: { data: 'fromA' }, + patches: [{ op: 'replace', path: ['data'], value: 'fromA' }], + }); + + publishStateChanged(messenger, 'ControllerB', { + state: { data: 'fromB' }, + patches: [{ op: 'replace', path: ['data'], value: 'fromB' }], + }); + + expect(store.get('ControllerA.data')).toBe('fromA'); + expect(store.get('ControllerB.data')).toBe('fromB'); + }); +}); + +describe('subscribeToChanges unsubscribe', () => { + let store: KeyValueStore; + + beforeEach(() => { + store = new KeyValueStore(':memory:'); + }); + + afterEach(() => { + store.close(); + }); + + it('self-unsubscribes when Wallet:destroyed is published', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['prop', true]]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + publishStateChanged(messenger, 'TestController', { + state: { prop: 'before' }, + patches: [{ op: 'replace', path: ['prop'], value: 'before' }], + }); + + expect(store.get('TestController.prop')).toBe('before'); + + messenger.publish('Wallet:destroyed'); + + publishStateChanged(messenger, 'TestController', { + state: { prop: 'after' }, + patches: [{ op: 'replace', path: ['prop'], value: 'after' }], + }); + + expect(store.get('TestController.prop')).toBe('before'); + }); + + it('stops persistence so writes to a subsequently closed store do not throw', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['prop', true]]), + }); + + const unsubscribe = subscribeToChanges( + messenger, + controllerMetadata, + store, + ); + + unsubscribe(); + store.close(); + + // This should not throw — the handler was unsubscribed before close. + expect(() => + publishStateChanged(messenger, 'TestController', { + state: { prop: 'after-close' }, + patches: [{ op: 'replace', path: ['prop'], value: 'after-close' }], + }), + ).not.toThrow(); + }); +}); + +type MockMetadata = Record< + string, + { + persist: boolean | ((value: never) => Json); + includeInDebugSnapshot: boolean; + includeInStateLogs: boolean; + usedInUi: boolean; + } +>; + +type MockControllers = { + messenger: TestMessenger; + controllerMetadata: Record; +}; + +/** + * Creates a state metadata object for a mock controller. + * + * @param properties - An array of [property name, persist value] pairs. + * @returns A mock metadata object. + */ +function createStateMetadata( + properties: [string, boolean | ((value: never) => Json)][], +): MockMetadata { + return Object.fromEntries( + properties.map(([name, persist]) => [ + name, + { + persist, + includeInDebugSnapshot: false, + includeInStateLogs: false, + usedInUi: false, + }, + ]), + ); +} + +/** + * Creates a mock messenger and controllerMetadata map for testing persistence + * wiring. The messenger supports subscribe/unsubscribe/publish. + * + * @param controllers - Map of controller names to their metadata. + * @returns A mock messenger and a controllerMetadata map. + */ +function createMockControllers( + controllers: Record, +): MockControllers { + const handlers = new Map void>>(); + + const messenger = { + subscribe: (eventType: string, handler: (...args: unknown[]) => void) => { + if (!handlers.has(eventType)) { + handlers.set(eventType, new Set()); + } + handlers.get(eventType)?.add(handler); + }, + unsubscribe: (eventType: string, handler: (...args: unknown[]) => void) => { + handlers.get(eventType)?.delete(handler); + }, + publish: (eventType: string, ...payload: unknown[]) => { + const subs = handlers.get(eventType); + if (subs) { + for (const handler of subs) { + handler(...payload); + } + } + }, + } as unknown as TestMessenger; + + const controllerMetadata: Record = {}; + for (const [name, metadata] of Object.entries(controllers)) { + controllerMetadata[name] = metadata; + } + + return { messenger, controllerMetadata }; +} + +/** + * Publishes a stateChanged event on the mock messenger. + * + * @param messenger - The mock messenger to publish on. + * @param controllerName - The name of the controller whose state changed. + * @param options - The state and patches to publish. + * @param options.state - The new controller state. + * @param options.patches - The Immer patches describing the state change. + */ +function publishStateChanged( + messenger: RootMessenger, + controllerName: string, + { state, patches }: { state: Record; patches: unknown[] }, +): void { + // @ts-expect-error Event type is dynamically constructed, but we know it's valid. + messenger.publish(`${controllerName}:stateChanged`, state, patches); +} diff --git a/packages/wallet/src/persistence/persistence.ts b/packages/wallet/src/persistence/persistence.ts new file mode 100644 index 00000000000..30ee2682c89 --- /dev/null +++ b/packages/wallet/src/persistence/persistence.ts @@ -0,0 +1,192 @@ +import type { StateMetadataConstraint } from '@metamask/base-controller'; +import { hasProperty } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; +import type { Patch } from 'immer'; + +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../initialization'; +import type { KeyValueStore } from './KeyValueStore'; + +/** + * Construct a store key from a controller name and property name. + * + * @param controllerName - The controller name. + * @param propertyName - The property name. + * @returns The store key in the format `ControllerName.propertyName`. + */ +function storeKey(controllerName: string, propertyName: string): string { + return `${controllerName}.${propertyName}`; +} + +/** + * Load persisted state from the key-value store and reconstruct it as + * a record keyed by controller name. + * + * Keys in the store follow the format `ControllerName.propertyName`. + * This function groups them into `{ [controllerName]: { [propertyName]: value } }`. + * + * @param store - The key-value store to read from. + * @returns A record of controller states, suitable for passing to `initialize()`. + */ +export function loadState( + store: KeyValueStore, +): Record> { + const allPairs = store.getAll(); + const state: Record> = {}; + + for (const [key, value] of Object.entries(allPairs)) { + const dotIndex = key.indexOf('.'); + if (dotIndex === -1) { + throw new Error( + `Invalid key in store: '${key}'. Expected format 'ControllerName.propertyName'.`, + ); + } + const controllerName = key.slice(0, dotIndex); + const propertyName = key.slice(dotIndex + 1); + + if (!controllerName || !propertyName) { + throw new Error( + `Invalid key in store: '${key}'. Both controller name and property name must be non-empty.`, + ); + } + + if (!state[controllerName]) { + state[controllerName] = {}; + } + state[controllerName][propertyName] = value; + } + return state; +} + +/** + * Subscribe to all controller `stateChanged` events and persist changes + * to the key-value store. + * + * For each controller's metadata, this function determines which state + * properties are persist-flagged. When a `stateChanged` event fires, it uses + * the Immer patches to identify which top-level properties changed, filters + * to only persist-flagged properties, and writes them to the store. + * + * Also subscribes to `Wallet:destroyed` — when the Wallet publishes that + * event, persistence tears itself down. + * + * @param messenger - The root messenger to subscribe on. + * @param controllerMetadata - A map from controller name to its state metadata. + * @param store - The key-value store to write to. + * @returns A function that unsubscribes all persistence handlers. + */ +export function subscribeToChanges( + messenger: RootMessenger, + controllerMetadata: Record, + store: KeyValueStore, +): () => void { + const unsubscribers: (() => void)[] = []; + + for (const [controllerName, metadata] of Object.entries(controllerMetadata)) { + const persistedProperties = getPersistPropertyNames(metadata); + if (persistedProperties.size === 0) { + continue; + } + + const eventType = `${controllerName}:stateChanged`; + + const handler = (state: Record, patches: Patch[]): void => { + const changed = getChangedProperties(patches, persistedProperties); + + for (const prop of changed) { + const key = storeKey(controllerName, prop); + + try { + if (!hasProperty(state, prop)) { + store.delete(key); + continue; + } + + const persistFlag = metadata[prop]?.persist; + + if (typeof persistFlag === 'function') { + store.set(key, persistFlag(state[prop] as never)); + } else { + store.set(key, state[prop]); + } + } catch (error) { + // TODO: Handle persistence failure to protect the user from data loss. + console.error(`Failed to persist state for ${key}`, error); + } + } + }; + + // @ts-expect-error Event type is dynamically constructed, but we know it's valid. + messenger.subscribe(eventType, handler); + + unsubscribers.push(() => { + // @ts-expect-error Event type is dynamically constructed, but we know it's valid. + messenger.unsubscribe(eventType, handler); + }); + } + + const unsubscribeAll = (): void => { + while (unsubscribers.length > 0) { + unsubscribers.pop()?.(); + } + }; + + const destroyedHandler = (): void => unsubscribeAll(); + messenger.subscribe('Wallet:destroyed', destroyedHandler); + unsubscribers.push(() => { + messenger.unsubscribe('Wallet:destroyed', destroyedHandler); + }); + + return unsubscribeAll; +} + +/** + * Get the set of property names whose `persist` metadata is truthy + * (either `true` or a `StateDeriver` function). + * + * @param metadata - The controller's state metadata. + * @returns A set of property names that should be persisted. + */ +function getPersistPropertyNames( + metadata: StateMetadataConstraint, +): ReadonlySet { + const names = new Set(); + for (const key of Object.keys(metadata)) { + if (metadata[key].persist) { + names.add(key); + } + } + return names; +} + +/** + * Extracts the set of persist-flagged top-level property names that changed + * from an array of Immer patches. + * + * If any patch has an empty path (indicating a root state replacement), + * all persist-flagged properties are returned. + * + * @param patches - Immer patches from a state update. + * @param persistedProperties - The set of persist-flagged property names. + * @returns A set of top-level property names that were modified. + */ +function getChangedProperties( + patches: Patch[], + persistedProperties: ReadonlySet, +): ReadonlySet { + const changed = new Set(); + for (const patch of patches) { + if (patch.path.length === 0) { + return persistedProperties; + } + + const prop = String(patch.path[0]); + if (persistedProperties.has(prop)) { + changed.add(prop); + } + } + return changed; +} diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts new file mode 100644 index 00000000000..865d6241e47 --- /dev/null +++ b/packages/wallet/src/types.ts @@ -0,0 +1,11 @@ +import type { ClientConfigApiService } from '@metamask/remote-feature-flag-controller'; +import type { Json } from '@metamask/utils'; + +export type WalletOptions = { + state?: Record>; + infuraProjectId: string; + clientVersion: string; + showApprovalRequest: () => void; + clientConfigApiService: ClientConfigApiService; + getMetaMetricsId: () => string; +}; diff --git a/packages/wallet/src/utilities.ts b/packages/wallet/src/utilities.ts new file mode 100644 index 00000000000..edd281f968b --- /dev/null +++ b/packages/wallet/src/utilities.ts @@ -0,0 +1,75 @@ +// TODO: Determine if these should be available directly on Wallet. +import { wordlist } from '@metamask/scure-bip39/dist/wordlists/english'; +import type { + AddTransactionOptions, + TransactionMeta, + TransactionParams, +} from '@metamask/transaction-controller'; + +import { Wallet } from './Wallet'; + +/** + * Import a secret recovery phrase using the wallet object. + * + * @param wallet - The wallet object. + * @param password - The password to the MetaMask wallet (not the SRP). + * @param phrase - The SRP as a string. + */ +export async function importSecretRecoveryPhrase( + wallet: Wallet, + password: string, + phrase: string, +): Promise { + const indices = phrase.split(' ').map((word) => wordlist.indexOf(word)); + const mnemonic = new Uint8Array(new Uint16Array(indices).buffer); + + // TODO: This should use the new MultichainAccountService. + await wallet.messenger.call( + 'KeyringController:createNewVaultAndRestore', + password, + mnemonic, + ); +} + +/** + * Initialize the wallet object with a randomly generated secret recovery phrase. + * + * @param wallet - The wallet object. + * @param password - The password to the MetaMask wallet (not the SRP). + */ +export async function createSecretRecoveryPhrase( + wallet: Wallet, + password: string, +): Promise { + // TODO: This should use the new MultichainAccountService. + await wallet.messenger.call( + 'KeyringController:createNewVaultAndKeychain', + password, + ); +} + +/** + * Sign a transaction using the wallet and submit it to the blockchain. + * + * @param wallet - The wallet object. + * @param transaction - The transaction. + * @param options - The transaction options (including which network to use). + * @returns The result. + */ +export async function sendTransaction( + wallet: Wallet, + transaction: TransactionParams, + options: AddTransactionOptions, +): Promise<{ transactionMeta: TransactionMeta; result: Promise }> { + const { transactionMeta, result } = await wallet.messenger.call( + 'TransactionController:addTransaction', + transaction, + options, + ); + + const approvalId = transactionMeta.id; + + await wallet.messenger.call('ApprovalController:acceptRequest', approvalId); + + return { transactionMeta, result }; +} diff --git a/packages/wallet/test/anvil.ts b/packages/wallet/test/anvil.ts new file mode 100644 index 00000000000..d871b865fc4 --- /dev/null +++ b/packages/wallet/test/anvil.ts @@ -0,0 +1,111 @@ +import { spawn } from 'node:child_process'; +import type { ChildProcess } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const ANVIL_STARTUP_TIMEOUT = 15_000; + +export type AnvilInstance = { + port: number; + rpcUrl: string; + stop: () => Promise; +}; + +/** + * Start a local Anvil dev chain instance. + * + * @param options - Options for the Anvil instance. + * @param options.mnemonic - The mnemonic to use for pre-funded accounts. + * @returns An object with the port, RPC URL, and a stop function. + */ +export async function startAnvil(options: { + mnemonic: string; +}): Promise { + const anvilBin = await getAnvilBinaryPath(); + + const proc: ChildProcess = spawn( + anvilBin, + ['--mnemonic', options.mnemonic, '--port', '0'], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + + const port = await waitForReady(proc); + const rpcUrl = `http://127.0.0.1:${port}`; + + return { + port, + rpcUrl, + stop: () => stopAnvil(proc), + }; +} + +async function getAnvilBinaryPath(): Promise { + const candidates = [ + resolve(__dirname, '../node_modules/.bin/anvil'), + resolve(__dirname, '../../../node_modules/.bin/anvil'), + ]; + + for (const candidate of candidates) { + try { + await access(candidate); + return candidate; + } catch { + // not found, try next + } + } + + throw new Error( + `Anvil binary not found. Run: yarn workspace @metamask/wallet run test:prepare`, + ); +} + +function waitForReady(proc: ChildProcess): Promise { + return new Promise((resolvePromise, reject) => { + const timeout = setTimeout(() => { + proc.kill(); + reject(new Error('Anvil failed to start within timeout')); + }, ANVIL_STARTUP_TIMEOUT); + + let output = ''; + proc.stdout?.on('data', (data: Buffer) => { + output += data.toString(); + const match = output.match(/Listening on [^\s:]+:(\d+)/u); + if (match) { + clearTimeout(timeout); + resolvePromise(Number(match[1])); + } + }); + + proc.stderr?.on('data', (data: Buffer) => { + output += data.toString(); + }); + + proc.on('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + + proc.on('exit', (code) => { + clearTimeout(timeout); + if (code !== null && code !== 0) { + reject(new Error(`Anvil exited with code ${code}:\n${output}`)); + } + }); + }); +} + +function stopAnvil(proc: ChildProcess): Promise { + return new Promise((resolvePromise) => { + if (proc.killed || proc.exitCode !== null) { + resolvePromise(); + return; + } + proc.on('exit', () => resolvePromise()); + proc.kill('SIGTERM'); + setTimeout(() => { + if (!proc.killed) { + proc.kill('SIGKILL'); + } + }, 5000).unref(); + }); +} diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json new file mode 100644 index 00000000000..a5e012287d5 --- /dev/null +++ b/packages/wallet/tsconfig.build.json @@ -0,0 +1,38 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../accounts-controller/tsconfig.build.json" + }, + { + "path": "../approval-controller/tsconfig.build.json" + }, + { + "path": "../connectivity-controller/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../network-controller/tsconfig.build.json" + }, + { + "path": "../remote-feature-flag-controller/tsconfig.build.json" + }, + { + "path": "../transaction-controller/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json new file mode 100644 index 00000000000..8f0b0c57883 --- /dev/null +++ b/packages/wallet/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../accounts-controller/tsconfig.json" + }, + { + "path": "../approval-controller/tsconfig.json" + }, + { + "path": "../connectivity-controller/tsconfig.json" + }, + { + "path": "../controller-utils/tsconfig.json" + }, + { + "path": "../keyring-controller/tsconfig.json" + }, + { + "path": "../messenger/tsconfig.json" + }, + { + "path": "../network-controller/tsconfig.json" + }, + { + "path": "../remote-feature-flag-controller/tsconfig.json" + }, + { + "path": "../transaction-controller/tsconfig.json" + } + ], + "include": ["../../types", "./src", "./test"] +} diff --git a/packages/wallet/typedoc.json b/packages/wallet/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/wallet/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index e00a3166a22..13d7ec6634f 100644 --- a/teams.json +++ b/teams.json @@ -81,5 +81,6 @@ "metamask/config-registry-controller": "team-networks", "metamask/money-account-controller": "team-accounts-framework", "metamask/money-account-upgrade-controller": "team-earn", - "metamask/snap-account-service": "team-accounts-framework" + "metamask/snap-account-service": "team-accounts-framework", + "metamask/wallet": "team-core-platform" } diff --git a/tsconfig.build.json b/tsconfig.build.json index eb60f52c10b..1604f654f55 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -255,6 +255,9 @@ }, { "path": "./packages/user-operation-controller/tsconfig.build.json" + }, + { + "path": "./packages/wallet/tsconfig.build.json" } ], "files": [], diff --git a/tsconfig.json b/tsconfig.json index e882beebed2..9ca28b560c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -244,6 +244,9 @@ }, { "path": "./packages/user-operation-controller" + }, + { + "path": "./packages/wallet" } ], "files": [], diff --git a/yarn.config.cjs b/yarn.config.cjs index 7efea310781..823af6d001b 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -158,11 +158,14 @@ module.exports = defineConfig({ ); // All non-root packages must have the same "test" script. - expectWorkspaceField( - workspace, - 'scripts.test', - 'NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter', - ); + // @metamask/wallet prepends an anvil binary download to the test script. + if (workspace.ident !== '@metamask/wallet') { + expectWorkspaceField( + workspace, + 'scripts.test', + 'NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter', + ); + } // All non-root packages must have the same "test:clean" script. expectWorkspaceField( diff --git a/yarn.lock b/yarn.lock index 8f7a365fd19..62b1c692471 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4035,7 +4035,7 @@ __metadata: languageName: node linkType: hard -"@metamask/foundryup@workspace:packages/foundryup": +"@metamask/foundryup@npm:^1.0.1, @metamask/foundryup@workspace:packages/foundryup": version: 0.0.0-use.local resolution: "@metamask/foundryup@workspace:packages/foundryup" dependencies: @@ -5812,6 +5812,39 @@ __metadata: languageName: node linkType: hard +"@metamask/wallet@workspace:packages/wallet": + version: 0.0.0-use.local + resolution: "@metamask/wallet@workspace:packages/wallet" + dependencies: + "@metamask/accounts-controller": "npm:^37.2.0" + "@metamask/approval-controller": "npm:^9.0.1" + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/browser-passworder": "npm:^6.0.0" + "@metamask/connectivity-controller": "npm:^0.2.0" + "@metamask/controller-utils": "npm:^11.20.0" + "@metamask/foundryup": "npm:^1.0.1" + "@metamask/keyring-controller": "npm:^25.2.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/network-controller": "npm:^30.0.1" + "@metamask/remote-feature-flag-controller": "npm:^4.2.0" + "@metamask/scure-bip39": "npm:^2.1.1" + "@metamask/transaction-controller": "npm:^65.0.0" + "@metamask/utils": "npm:^11.9.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/better-sqlite3": "npm:^7.6.13" + "@types/jest": "npm:^29.5.14" + better-sqlite3: "npm:^12.9.0" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + nock: "npm:^13.3.1" + 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 + "@myx-trade/sdk@npm:^0.1.265": version: 0.1.265 resolution: "@myx-trade/sdk@npm:0.1.265" @@ -6838,6 +6871,15 @@ __metadata: languageName: node linkType: hard +"@types/better-sqlite3@npm:^7.6.13": + version: 7.6.13 + resolution: "@types/better-sqlite3@npm:7.6.13" + dependencies: + "@types/node": "npm:*" + checksum: 10/c74dafa3c550ac866737870016d7b1a735c7d450c16d40962eeb54510fa150e91752bfdf678f55e91894d8853771b95f909b0062122116cddac4d80491b74411 + languageName: node + linkType: hard + "@types/bn.js@npm:*, @types/bn.js@npm:^5.1.0, @types/bn.js@npm:^5.1.5": version: 5.1.6 resolution: "@types/bn.js@npm:5.1.6" @@ -7886,6 +7928,17 @@ __metadata: languageName: node linkType: hard +"better-sqlite3@npm:^12.9.0": + version: 12.9.0 + resolution: "better-sqlite3@npm:12.9.0" + dependencies: + bindings: "npm:^1.5.0" + node-gyp: "npm:latest" + prebuild-install: "npm:^7.1.1" + checksum: 10/0b32b06140f2a98ce7fbcf7d30b56b46e16d0b6fbfb63157a7bb61dea7265e176ab362d439785e852716a03a130db5f0ab356b6a68cbcb23d59a362c1a8b01d4 + languageName: node + linkType: hard + "bignumber.js@npm:^9.1.2": version: 9.1.2 resolution: "bignumber.js@npm:9.1.2" @@ -7905,6 +7958,15 @@ __metadata: languageName: node linkType: hard +"bindings@npm:^1.5.0": + version: 1.5.0 + resolution: "bindings@npm:1.5.0" + dependencies: + file-uri-to-path: "npm:1.0.0" + checksum: 10/593d5ae975ffba15fbbb4788fe5abd1e125afbab849ab967ab43691d27d6483751805d98cb92f7ac24a2439a8a8678cd0131c535d5d63de84e383b0ce2786133 + languageName: node + linkType: hard + "bitcoin-address-validation@npm:^2.2.3": version: 2.2.3 resolution: "bitcoin-address-validation@npm:2.2.3" @@ -7916,6 +7978,17 @@ __metadata: languageName: node linkType: hard +"bl@npm:^4.0.3": + version: 4.1.0 + resolution: "bl@npm:4.1.0" + dependencies: + buffer: "npm:^5.5.0" + inherits: "npm:^2.0.4" + readable-stream: "npm:^3.4.0" + checksum: 10/b7904e66ed0bdfc813c06ea6c3e35eafecb104369dbf5356d0f416af90c1546de3b74e5b63506f0629acf5e16a6f87c3798f16233dcff086e9129383aa02ab55 + languageName: node + linkType: hard + "blakejs@npm:^1.1.0": version: 1.2.1 resolution: "blakejs@npm:1.2.1" @@ -8093,6 +8166,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^5.5.0": + version: 5.7.1 + resolution: "buffer@npm:5.7.1" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.1.13" + checksum: 10/997434d3c6e3b39e0be479a80288875f71cd1c07d75a3855e6f08ef848a3c966023f79534e22e415ff3a5112708ce06127277ab20e527146d55c84566405c7c6 + languageName: node + linkType: hard + "buffer@npm:^6.0.3": version: 6.0.3 resolution: "buffer@npm:6.0.3" @@ -8238,6 +8321,13 @@ __metadata: languageName: node linkType: hard +"chownr@npm:^1.1.1": + version: 1.1.4 + resolution: "chownr@npm:1.1.4" + checksum: 10/115648f8eb38bac5e41c3857f3e663f9c39ed6480d1349977c4d96c95a47266fcacc5a5aabf3cb6c481e22d72f41992827db47301851766c4fd77ac21a4f081d + languageName: node + linkType: hard + "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -8713,6 +8803,15 @@ __metadata: languageName: node linkType: hard +"decompress-response@npm:^6.0.0": + version: 6.0.0 + resolution: "decompress-response@npm:6.0.0" + dependencies: + mimic-response: "npm:^3.1.0" + checksum: 10/d377cf47e02d805e283866c3f50d3d21578b779731e8c5072d6ce8c13cc31493db1c2f6784da9d1d5250822120cefa44f1deab112d5981015f2e17444b763812 + languageName: node + linkType: hard + "dedent@npm:^1.0.0": version: 1.7.1 resolution: "dedent@npm:1.7.1" @@ -8725,6 +8824,13 @@ __metadata: languageName: node linkType: hard +"deep-extend@npm:^0.6.0": + version: 0.6.0 + resolution: "deep-extend@npm:0.6.0" + checksum: 10/7be7e5a8d468d6b10e6a67c3de828f55001b6eb515d014f7aeb9066ce36bd5717161eb47d6a0f7bed8a9083935b465bc163ee2581c8b128d29bf61092fdf57a7 + languageName: node + linkType: hard + "deep-freeze-strict@npm:^1.1.1": version: 1.1.1 resolution: "deep-freeze-strict@npm:1.1.1" @@ -8863,6 +8969,13 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^2.0.0": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10/b736c8d97d5d46164c0d1bed53eb4e6a3b1d8530d460211e2d52f1c552875e706c58a5376854e4e54f8b828c9cada58c855288c968522eb93ac7696d65970766 + languageName: node + linkType: hard + "detect-newline@npm:^3.0.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" @@ -9018,6 +9131,15 @@ __metadata: languageName: node linkType: hard +"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1": + version: 1.4.5 + resolution: "end-of-stream@npm:1.4.5" + dependencies: + once: "npm:^1.4.0" + checksum: 10/1e0cfa6e7f49887544e03314f9dfc56a8cb6dde910cbb445983ecc2ff426fc05946df9d75d8a21a3a64f2cecfe1bf88f773952029f46756b2ed64a24e95b1fb8 + languageName: node + linkType: hard + "enhanced-resolve@npm:^5.15.0, enhanced-resolve@npm:^5.17.1": version: 5.18.0 resolution: "enhanced-resolve@npm:5.18.0" @@ -9776,6 +9898,13 @@ __metadata: languageName: node linkType: hard +"expand-template@npm:^2.0.3": + version: 2.0.3 + resolution: "expand-template@npm:2.0.3" + checksum: 10/588c19847216421ed92befb521767b7018dc88f88b0576df98cb242f20961425e96a92cbece525ef28cc5becceae5d544ae0f5b9b5e2aa05acb13716ca5b3099 + languageName: node + linkType: hard + "expand-tilde@npm:^2.0.0, expand-tilde@npm:^2.0.2": version: 2.0.2 resolution: "expand-tilde@npm:2.0.2" @@ -10036,6 +10165,13 @@ __metadata: languageName: node linkType: hard +"file-uri-to-path@npm:1.0.0": + version: 1.0.0 + resolution: "file-uri-to-path@npm:1.0.0" + checksum: 10/b648580bdd893a008c92c7ecc96c3ee57a5e7b6c4c18a9a09b44fb5d36d79146f8e442578bc0e173dc027adf3987e254ba1dfd6e3ec998b7c282873010502144 + languageName: node + linkType: hard + "fill-range@npm:^7.1.1": version: 7.1.1 resolution: "fill-range@npm:7.1.1" @@ -10197,6 +10333,13 @@ __metadata: languageName: node linkType: hard +"fs-constants@npm:^1.0.0": + version: 1.0.0 + resolution: "fs-constants@npm:1.0.0" + checksum: 10/18f5b718371816155849475ac36c7d0b24d39a11d91348cfcb308b4494824413e03572c403c86d3a260e049465518c4f0d5bd00f0371cdfcad6d4f30a85b350d + languageName: node + linkType: hard + "fs-extra@npm:^10.1.0": version: 10.1.0 resolution: "fs-extra@npm:10.1.0" @@ -10359,6 +10502,13 @@ __metadata: languageName: node linkType: hard +"github-from-package@npm:0.0.0": + version: 0.0.0 + resolution: "github-from-package@npm:0.0.0" + checksum: 10/2a091ba07fbce22205642543b4ea8aaf068397e1433c00ae0f9de36a3607baf5bcc14da97fbb798cfca6393b3c402031fca06d8b491a44206d6efef391c58537 + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -10737,7 +10887,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.2.1": +"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: 10/d9f2557a59036f16c282aaeb107832dc957a93d73397d89bbad4eb1130560560eb695060145e8e6b3b498b15ab95510226649a0b8f52ae06583575419fe10fc4 @@ -10818,7 +10968,7 @@ __metadata: languageName: node linkType: hard -"ini@npm:^1.3.4": +"ini@npm:^1.3.4, ini@npm:~1.3.0": version: 1.3.8 resolution: "ini@npm:1.3.8" checksum: 10/314ae176e8d4deb3def56106da8002b462221c174ddb7ce0c49ee72c8cd1f9044f7b10cc555a7d8850982c3b9ca96fc212122749f5234bc2b6fb05fb942ed566 @@ -12317,6 +12467,13 @@ __metadata: languageName: node linkType: hard +"mimic-response@npm:^3.1.0": + version: 3.1.0 + resolution: "mimic-response@npm:3.1.0" + checksum: 10/7e719047612411fe071332a7498cf0448bbe43c485c0d780046c76633a771b223ff49bd00267be122cedebb897037fdb527df72335d0d0f74724604ca70b37ad + languageName: node + linkType: hard + "min-indent@npm:^1.0.0": version: 1.0.1 resolution: "min-indent@npm:1.0.1" @@ -12376,7 +12533,7 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.5": +"minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.5": version: 1.2.8 resolution: "minimist@npm:1.2.8" checksum: 10/908491b6cc15a6c440ba5b22780a0ba89b9810e1aea684e253e43c4e3b8d56ec1dcdd7ea96dde119c29df59c936cde16062159eae4225c691e19c70b432b6e6f @@ -12483,6 +12640,13 @@ __metadata: languageName: node linkType: hard +"mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3": + version: 0.5.3 + resolution: "mkdirp-classic@npm:0.5.3" + checksum: 10/3f4e088208270bbcc148d53b73e9a5bd9eef05ad2cbf3b3d0ff8795278d50dd1d11a8ef1875ff5aea3fa888931f95bfcb2ad5b7c1061cfefd6284d199e6776ac + languageName: node + linkType: hard + "mkdirp@npm:^1.0.3": version: 1.0.4 resolution: "mkdirp@npm:1.0.4" @@ -12558,6 +12722,13 @@ __metadata: languageName: node linkType: hard +"napi-build-utils@npm:^2.0.0": + version: 2.0.0 + resolution: "napi-build-utils@npm:2.0.0" + checksum: 10/69adcdb828481737f1ec64440286013f6479d5b264e24d5439ba795f65293d0bb6d962035de07c65fae525ed7d2fcd0baab6891d8e3734ea792fec43918acf83 + languageName: node + linkType: hard + "natural-compare@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare@npm:1.4.0" @@ -12590,6 +12761,15 @@ __metadata: languageName: node linkType: hard +"node-abi@npm:^3.3.0": + version: 3.89.0 + resolution: "node-abi@npm:3.89.0" + dependencies: + semver: "npm:^7.3.5" + checksum: 10/8fc84f775475b81256cf208c0ed79fe53eca6e5f5e1fe8c263f63f0250b9454b9f3a144f389bf4dbda258f5f916ee0bbc5bb0ebf0f13de78fd80390dac0227b7 + languageName: node + linkType: hard + "node-addon-api@npm:^2.0.0": version: 2.0.2 resolution: "node-addon-api@npm:2.0.2" @@ -12811,7 +12991,7 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.3.0, once@npm:^1.4.0": +"once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -13233,6 +13413,28 @@ __metadata: languageName: node linkType: hard +"prebuild-install@npm:^7.1.1": + version: 7.1.3 + resolution: "prebuild-install@npm:7.1.3" + dependencies: + detect-libc: "npm:^2.0.0" + expand-template: "npm:^2.0.3" + github-from-package: "npm:0.0.0" + minimist: "npm:^1.2.3" + mkdirp-classic: "npm:^0.5.3" + napi-build-utils: "npm:^2.0.0" + node-abi: "npm:^3.3.0" + pump: "npm:^3.0.0" + rc: "npm:^1.2.7" + simple-get: "npm:^4.0.0" + tar-fs: "npm:^2.0.0" + tunnel-agent: "npm:^0.6.0" + bin: + prebuild-install: bin.js + checksum: 10/1b7e4c00d2750b532a4fc2a83ffb0c5fefa1b6f2ad071896ead15eeadc3255f5babd816949991af083cf7429e375ae8c7d1c51f73658559da36f948a020a3a11 + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -13387,6 +13589,16 @@ __metadata: languageName: node linkType: hard +"pump@npm:^3.0.0": + version: 3.0.4 + resolution: "pump@npm:3.0.4" + dependencies: + end-of-stream: "npm:^1.1.0" + once: "npm:^1.3.1" + checksum: 10/d043c3e710c56ffd280711e98a94e863ab334f79ea43cee0fb70e1349b2355ffd2ff287c7522e4c960a247699d5b7825f00fa090b85d6179c973be13f78a6c49 + languageName: node + linkType: hard + "punycode@npm:2.1.0": version: 2.1.0 resolution: "punycode@npm:2.1.0" @@ -13473,6 +13685,20 @@ __metadata: languageName: node linkType: hard +"rc@npm:^1.2.7": + version: 1.2.8 + resolution: "rc@npm:1.2.8" + dependencies: + deep-extend: "npm:^0.6.0" + ini: "npm:~1.3.0" + minimist: "npm:^1.2.0" + strip-json-comments: "npm:~2.0.1" + bin: + rc: ./cli.js + checksum: 10/5c4d72ae7eec44357171585938c85ce066da8ca79146b5635baf3d55d74584c92575fa4e2c9eac03efbed3b46a0b2e7c30634c012b4b4fa40d654353d3c163eb + languageName: node + linkType: hard + "react-is@npm:^18.0.0": version: 18.3.1 resolution: "react-is@npm:18.3.1" @@ -13510,7 +13736,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:3.6.2, readable-stream@npm:^3.0.2, readable-stream@npm:^3.6.0, readable-stream@npm:^3.6.2": +"readable-stream@npm:3.6.2, readable-stream@npm:^3.0.2, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0, readable-stream@npm:^3.6.2": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -14059,6 +14285,24 @@ __metadata: languageName: node linkType: hard +"simple-concat@npm:^1.0.0": + version: 1.0.1 + resolution: "simple-concat@npm:1.0.1" + checksum: 10/4d211042cc3d73a718c21ac6c4e7d7a0363e184be6a5ad25c8a1502e49df6d0a0253979e3d50dbdd3f60ef6c6c58d756b5d66ac1e05cda9cacd2e9fc59e3876a + languageName: node + linkType: hard + +"simple-get@npm:^4.0.0": + version: 4.0.1 + resolution: "simple-get@npm:4.0.1" + dependencies: + decompress-response: "npm:^6.0.0" + once: "npm:^1.3.1" + simple-concat: "npm:^1.0.0" + checksum: 10/93f1b32319782f78f2f2234e9ce34891b7ab6b990d19d8afefaa44423f5235ce2676aae42d6743fecac6c8dfff4b808d4c24fe5265be813d04769917a9a44f36 + languageName: node + linkType: hard + "simple-git-hooks@npm:^2.8.0": version: 2.11.1 resolution: "simple-git-hooks@npm:2.11.1" @@ -14381,6 +14625,13 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:~2.0.1": + version: 2.0.1 + resolution: "strip-json-comments@npm:2.0.1" + checksum: 10/1074ccb63270d32ca28edfb0a281c96b94dc679077828135141f27d52a5a398ef5e78bcf22809d23cadc2b81dfbe345eb5fd8699b385c8b1128907dec4a7d1e1 + languageName: node + linkType: hard + "strnum@npm:^2.2.2": version: 2.2.2 resolution: "strnum@npm:2.2.2" @@ -14447,6 +14698,31 @@ __metadata: languageName: node linkType: hard +"tar-fs@npm:^2.0.0": + version: 2.1.4 + resolution: "tar-fs@npm:2.1.4" + dependencies: + chownr: "npm:^1.1.1" + mkdirp-classic: "npm:^0.5.2" + pump: "npm:^3.0.0" + tar-stream: "npm:^2.1.4" + checksum: 10/bdf7e3cb039522e39c6dae3084b1bca8d7bcc1de1906eae4a1caea6a2250d22d26dcc234118bf879b345d91ebf250a744b196e379334a4abcbb109a78db7d3be + languageName: node + linkType: hard + +"tar-stream@npm:^2.1.4": + version: 2.2.0 + resolution: "tar-stream@npm:2.2.0" + dependencies: + bl: "npm:^4.0.3" + end-of-stream: "npm:^1.4.1" + fs-constants: "npm:^1.0.0" + inherits: "npm:^2.0.3" + readable-stream: "npm:^3.1.1" + checksum: 10/1a52a51d240c118cbcd30f7368ea5e5baef1eac3e6b793fb1a41e6cd7319296c79c0264ccc5859f5294aa80f8f00b9239d519e627b9aade80038de6f966fec6a + languageName: node + linkType: hard + "tar-stream@npm:^3.1.7": version: 3.1.7 resolution: "tar-stream@npm:3.1.7" @@ -14677,6 +14953,15 @@ __metadata: languageName: node linkType: hard +"tunnel-agent@npm:^0.6.0": + version: 0.6.0 + resolution: "tunnel-agent@npm:0.6.0" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10/7f0d9ed5c22404072b2ae8edc45c071772affd2ed14a74f03b4e71b4dd1a14c3714d85aed64abcaaee5fec2efc79002ba81155c708f4df65821b444abb0cfade + languageName: node + linkType: hard + "tweetnacl@npm:^1.0.3": version: 1.0.3 resolution: "tweetnacl@npm:1.0.3"