diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7625784448..f5c519f5b9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -36,6 +36,9 @@ /packages/transaction-pay-controller @MetaMask/confirmations /packages/user-operation-controller @MetaMask/confirmations +## Transactions Team +/packages/smart-transactions-controller @MetaMask/transactions + ## Delegation Team /packages/delegation-controller @MetaMask/delegation /packages/gator-permissions-controller @MetaMask/delegation @@ -213,6 +216,8 @@ /packages/selected-network-controller/CHANGELOG.md @MetaMask/wallet-integrations @MetaMask/core-platform /packages/signature-controller/package.json @MetaMask/confirmations @MetaMask/core-platform /packages/signature-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/smart-transactions-controller/package.json @MetaMask/transactions @MetaMask/core-platform +/packages/smart-transactions-controller/CHANGELOG.md @MetaMask/transactions @MetaMask/core-platform /packages/transaction-controller/package.json @MetaMask/confirmations @MetaMask/core-platform /packages/transaction-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform /packages/transaction-pay-controller/package.json @MetaMask/confirmations @MetaMask/core-platform diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 7035a90e6e..5047f6a11a 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -135,20 +135,66 @@ jobs: exit 1 fi - test: - name: Test + # The following `test-*` jobs are duplicated because a single job may only + # create a maximum of 256 matrix combinations, and we have more than 256 total + # test combinations across all Node.js versions. + test-18: + name: Test (18.x) runs-on: ubuntu-latest needs: prepare strategy: matrix: - node-version: [18.x, 20.x, 22.x] package-name: ${{ fromJson(needs.prepare.outputs.child-workspace-package-names) }} steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v2 with: is-high-risk-environment: false - node-version: ${{ matrix.node-version }} + node-version: 18.x + - run: yarn workspace ${{ matrix.package-name }} run test + - name: Require clean working directory + shell: bash + run: | + if ! git diff --exit-code; then + echo "Working tree dirty at end of job" + exit 1 + fi + + test-20: + name: Test (20.x) + runs-on: ubuntu-latest + needs: prepare + strategy: + matrix: + package-name: ${{ fromJson(needs.prepare.outputs.child-workspace-package-names) }} + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v2 + with: + is-high-risk-environment: false + node-version: 20.x + - run: yarn workspace ${{ matrix.package-name }} run test + - name: Require clean working directory + shell: bash + run: | + if ! git diff --exit-code; then + echo "Working tree dirty at end of job" + exit 1 + fi + + test-22: + name: Test (22.x) + runs-on: ubuntu-latest + needs: prepare + strategy: + matrix: + package-name: ${{ fromJson(needs.prepare.outputs.child-workspace-package-names) }} + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v2 + with: + is-high-risk-environment: false + node-version: 22.x - run: yarn workspace ${{ matrix.package-name }} run test - name: Require clean working directory shell: bash diff --git a/README.md b/README.md index f4a317c926..d8c35232e9 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ yarn skills --reset # clear saved local selection - [`@metamask/selected-network-controller`](packages/selected-network-controller) - [`@metamask/shield-controller`](packages/shield-controller) - [`@metamask/signature-controller`](packages/signature-controller) +- [`@metamask/smart-transactions-controller`](packages/smart-transactions-controller) - [`@metamask/snap-account-service`](packages/snap-account-service) - [`@metamask/social-controllers`](packages/social-controllers) - [`@metamask/storage-service`](packages/storage-service) @@ -210,6 +211,7 @@ linkStyle default opacity:0.5 selected_network_controller(["@metamask/selected-network-controller"]); shield_controller(["@metamask/shield-controller"]); signature_controller(["@metamask/signature-controller"]); + smart_transactions_controller(["@metamask/smart-transactions-controller"]); snap_account_service(["@metamask/snap-account-service"]); social_controllers(["@metamask/social-controllers"]); storage_service(["@metamask/storage-service"]); @@ -537,6 +539,16 @@ linkStyle default opacity:0.5 signature_controller --> logging_controller; signature_controller --> messenger; signature_controller --> network_controller; + smart_transactions_controller --> base_controller; + smart_transactions_controller --> controller_utils; + smart_transactions_controller --> eth_json_rpc_provider; + smart_transactions_controller --> messenger; + smart_transactions_controller --> network_controller; + smart_transactions_controller --> polling_controller; + smart_transactions_controller --> profile_sync_controller; + smart_transactions_controller --> remote_feature_flag_controller; + smart_transactions_controller --> transaction_controller; + smart_transactions_controller --> json_rpc_engine; snap_account_service --> account_tree_controller; snap_account_service --> keyring_controller; snap_account_service --> messenger; @@ -596,6 +608,8 @@ linkStyle default opacity:0.5 wallet --> messenger; wallet --> remote_feature_flag_controller; wallet --> storage_service; + wallet_cli --> base_controller; + wallet_cli --> wallet; ``` diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 4b9a2704a4..85127a3df6 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2136,6 +2136,82 @@ "count": 2 } }, + "packages/smart-transactions-controller/src/SmartTransactionsController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 12 + }, + "@typescript-eslint/no-explicit-any": { + "count": 3 + }, + "no-restricted-syntax": { + "count": 12 + } + }, + "packages/smart-transactions-controller/src/SmartTransactionsController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 10 + }, + "@typescript-eslint/no-base-to-string": { + "count": 1 + }, + "@typescript-eslint/no-floating-promises": { + "count": 4 + }, + "@typescript-eslint/restrict-template-expressions": { + "count": 1 + }, + "no-restricted-syntax": { + "count": 4 + } + }, + "packages/smart-transactions-controller/src/featureFlags/feature-flags.ts": { + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/smart-transactions-controller/src/selectors.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/smart-transactions-controller/src/types.ts": { + "@typescript-eslint/naming-convention": { + "count": 25 + }, + "@typescript-eslint/no-explicit-any": { + "count": 7 + }, + "@typescript-eslint/prefer-enum-initializers": { + "count": 6 + } + }, + "packages/smart-transactions-controller/src/utils.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + } + }, + "packages/smart-transactions-controller/src/utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 10 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "@typescript-eslint/no-explicit-any": { + "count": 4 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "jsdoc/require-param-description": { + "count": 1 + } + }, + "packages/smart-transactions-controller/tests/helpers.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, "packages/snap-account-service/src/SnapAccountService.test.ts": { "no-restricted-syntax": { "count": 2 diff --git a/eslint.config.mjs b/eslint.config.mjs index 05b9a4f3d5..c417e7b8f6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -77,6 +77,7 @@ const config = createConfig([ '**/coverage/**', '**/dist/**', '**/docs/**', + '.skills-cache/**', '.yarn/**', 'merged-packages/**', 'scripts/create-package/package-template/**', diff --git a/merged-packages/smart-transactions-controller/README.md b/merged-packages/smart-transactions-controller/README.md deleted file mode 100644 index cb9bfbecfe..0000000000 --- a/merged-packages/smart-transactions-controller/README.md +++ /dev/null @@ -1,152 +0,0 @@ -

⚠️ PLEASE READ ⚠️

This package is currently being migrated to our core monorepo. Please do not make any commits to this repository while this migration is taking place, as they will not be transferred over. Also, please re-open PRs that are under active development in the core repo.

- -# `@metamask/smart-transactions-controller` - -Improves success rates for swaps by trialing transactions privately and finding minimum fees. - -## Installation - -`yarn add @metamask/smart-transactions-controller` - -or - -`npm install @metamask/smart-transactions-controller` - -## Contributing - -### Setup - -- Install the current LTS version of [Node.js](https://nodejs.org) - - If you are using [nvm](https://github.com/creationix/nvm#installation) (recommended) running `nvm install` will install the latest suitable version and running `nvm use` will automatically choose the right node version for you. -- Install [Yarn v3](https://yarnpkg.com/getting-started/install) -- Run `yarn install` to install dependencies and run any required post-install scripts - -### Testing and Linting - -Run `yarn test` to run the tests once. To run tests on file changes, run `yarn test:watch`. - -Run `yarn lint` to run the linter, or run `yarn lint:fix` to run the linter and fix any automatically fixable issues. - -### Feature Flags - -Smart transactions feature flags are managed via `RemoteFeatureFlagController` (LaunchDarkly). The configuration uses a `default` remote object for global settings and chain-specific overrides keyed by hex chain ID. - -The flag in LaunchDarkly is named `smartTransactionsNetworks`. - -#### Adding a New Flag - -1. **Add the field to the schema** in `src/utils/validators.ts`: - - ```typescript - export const SmartTransactionsNetworkConfigSchema = type({ - // ... existing fields - myNewFlag: optional(boolean()), - }); - ``` - - The `SmartTransactionsNetworkConfig` type is automatically inferred from this schema. - -2. **Add default value** in `src/constants.ts` under `DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS`: - - These values should be defensive. They are applied when the remote config is invalid or does not exist for a network. - It disables smart transaction. - - ```typescript - export const DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS = { - default: { - // ... existing defaults - myNewFlag: false, - }, - }; - ``` - -3. **Use in clients** via the exported selectors: - - ```typescript - import { selectSmartTransactionsFeatureFlagsForChain } from '@metamask/smart-transactions-controller'; - - const chainConfig = selectSmartTransactionsFeatureFlagsForChain( - state, - '0x1', - ); - if (chainConfig.myNewFlag) { - // Feature is enabled - } - ``` - -### Release & Publishing - -The project follows the same release process as the other libraries in the MetaMask organization. The GitHub Actions [`action-create-release-pr`](https://github.com/MetaMask/action-create-release-pr) and [`action-publish-release`](https://github.com/MetaMask/action-publish-release) are used to automate the release process; see those repositories for more information about how they work. - -1. Choose a release version. - - - The release version should be chosen according to SemVer. Analyze the changes to see whether they include any breaking changes, new features, or deprecations, then choose the appropriate SemVer version. See [the SemVer specification](https://semver.org/) for more information. - -2. If this release is backporting changes onto a previous release, then ensure there is a major version branch for that version (e.g. `1.x` for a `v1` backport release). - - - The major version branch should be set to the most recent release with that major version. For example, when backporting a `v1.0.2` release, you'd want to ensure there was a `1.x` branch that was set to the `v1.0.1` tag. - -3. Trigger the [`workflow_dispatch`](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) event [manually](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow) for the `Create Release Pull Request` action to create the release PR. - - - For a backport release, the base branch should be the major version branch that you ensured existed in step 2. For a normal release, the base branch should be the main branch for that repository (which should be the default value). - - This should trigger the [`action-create-release-pr`](https://github.com/MetaMask/action-create-release-pr) workflow to create the release PR. - -4. Update the changelog to move each change entry into the appropriate change category ([See here](https://keepachangelog.com/en/1.0.0/#types) for the full list of change categories, and the correct ordering), and edit them to be more easily understood by users of the package. - - - Generally any changes that don't affect consumers of the package (e.g. lockfile changes or development environment changes) are omitted. Exceptions may be made for changes that might be of interest despite not having an effect upon the published package (e.g. major test improvements, security improvements, improved documentation, etc.). - - Try to explain each change in terms that users of the package would understand (e.g. avoid referencing internal variables/concepts). - - Consolidate related changes into one change entry if it makes it easier to explain. - - Run `yarn auto-changelog validate --rc` to check that the changelog is correctly formatted. - -5. Review and QA the release. - - - If changes are made to the base branch, the release branch will need to be updated with these changes and review/QA will need to restart again. As such, it's probably best to avoid merging other PRs into the base branch while review is underway. - -6. Squash & Merge the release. - - - This should trigger the [`action-publish-release`](https://github.com/MetaMask/action-publish-release) workflow to tag the final release commit and publish the release on GitHub. - -7. Publish the release on npm. - - - Wait for the `publish-release` GitHub Action workflow to finish. This should trigger a second job (`publish-npm`), which will wait for a run approval by the [`npm publishers`](https://github.com/orgs/MetaMask/teams/npm-publishers) team. - - Approve the `publish-npm` job (or ask somebody on the npm publishers team to approve it for you). - - Once the `publish-npm` job has finished, check npm to verify that it has been published. - -### Testing changes in other projects using preview builds - -If you are working on a pull request and want to test changes in another project before you publish them, you can create a _preview build_ and then configure your project to use it. - -#### Creating a preview build - -1. Within your pull request, post a comment with the text `@metamaskbot publish-preview`. This starts the `publish-preview` GitHub action, which will create a preview build and publish it to NPM. -2. After a few minutes, the action should complete and you will see a new comment. Note two things: - - The name is scoped to `@metamask-previews` instead of `@metamask`. - - The ID of the last commit in the branch is appended to the version, e.g. `1.2.3-preview-e2df9b4` instead of `1.2.3`. - -#### Using a preview build - -To use a preview build within a project, you need to override the resolution logic for your package manager so that the "production" version of that package is replaced with the preview version. Here's how you do that: - -1. Open `package.json` in the project and locate the entry for this package in `dependencies`. -2. Locate the section responsible for resolution overrides (or create it if it doesn't exist). If you're using Yarn, this is `resolutions`; if you're using NPM or any other package manager, this is `overrides`. -3. Add a line to this section that mirrors the dependency entry on the left-hand side and points to the preview version on the right-hand side. Note the exact format of the left-hand side will differ based on which version of Yarn or NPM you are using. For example: - - For Yarn Modern, you will add something like this to `resolutions`: - ``` - "@metamask/smart-transactions-controller@^1.2.3": "npm:@metamask-previews/smart-transactions-controller@1.2.3-preview-abcdefg" - ``` - - For Yarn Classic, you will add something like this to `resolutions`: - ``` - "@metamask/smart-transactions-controller": "npm:@metamask-previews/smart-transactions-controller@1.2.3-preview-abcdefg" - ``` - - For NPM, you will add something like this to `overrides`: - ``` - "@metamask/smart-transactions-controller": "npm:@metamask-previews/smart-transactions-controller@1.2.3-preview-abcdefg" - ``` -4. Run `yarn install`. - -#### Updating a preview build - -If you make more changes to your pull request and want to create a new preview build: - -1. Post another `@metamaskbot` comment on the pull request and wait for the response. -2. Update the version of the preview build in your project's `package.json`. Make sure to re-run `yarn install`! diff --git a/merged-packages/smart-transactions-controller/setupJest.js b/merged-packages/smart-transactions-controller/setupJest.js deleted file mode 100644 index 19c0b2eae9..0000000000 --- a/merged-packages/smart-transactions-controller/setupJest.js +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import-x/no-unassigned-import -require('isomorphic-fetch'); diff --git a/package.json b/package.json index a9af96e987..72e5fd1f45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "1044.0.0", + "version": "1046.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 8f438f3d3b..a06cd815fe 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional `environment_type` property to the `ButtonClicked` unified swap/bridge event context ([#9121](https://github.com/MetaMask/core/pull/9121)) + ### Changed - Bump `@metamask/assets-controllers` from `^109.0.0` to `^109.1.0` ([#9110](https://github.com/MetaMask/core/pull/9110)) diff --git a/packages/bridge-controller/src/utils/metrics/types.ts b/packages/bridge-controller/src/utils/metrics/types.ts index b766a6ba01..ed34dfb9eb 100644 --- a/packages/bridge-controller/src/utils/metrics/types.ts +++ b/packages/bridge-controller/src/utils/metrics/types.ts @@ -111,7 +111,7 @@ type RequiredEventContextFromClientBase = { [UnifiedSwapBridgeEventName.ButtonClicked]: Pick< RequestParams, 'token_symbol_source' | 'token_symbol_destination' - >; + > & { environment_type?: string }; // When type is object, the payload can be anything [UnifiedSwapBridgeEventName.PageViewed]: object; [UnifiedSwapBridgeEventName.InputChanged]: { diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index fcff6929fd..e648f0cf36 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The default `policyOptions.circuitBreakDuration` is now `30` seconds. - The default `pollingInterval` for the block tracker is now `20` seconds. - The default `retryTimeout` for the block tracker is now `20` seconds. +- Add `failoverUrls` constructor argument ([#9140](https://github.com/MetaMask/core/pull/9140)) + - These will override `failoverUrls` from state during network client creation. ### Changed diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index ab34ec507e..549488ebdd 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -739,6 +739,10 @@ export type NetworkControllerOptions = { * The API key for Infura, used to make requests to Infura. */ infuraProjectId: string; + /** + * An optional map of available failover URLs for each chain ID. + */ + failoverUrls?: Record; /** * The desired state with which to initialize this controller. * Missing properties will be filled in with defaults. For instance, if not @@ -1256,6 +1260,8 @@ export class NetworkController extends BaseController< readonly #infuraProjectId: string; + readonly #failoverUrls?: Record; + #previouslySelectedNetworkClientId: string; #providerProxy: ProviderProxy | undefined; @@ -1291,6 +1297,7 @@ export class NetworkController extends BaseController< messenger, state, infuraProjectId, + failoverUrls, log, getRpcServiceOptions, getBlockTrackerOptions, @@ -1333,6 +1340,7 @@ export class NetworkController extends BaseController< }); this.#infuraProjectId = infuraProjectId; + this.#failoverUrls = failoverUrls; this.#log = log; this.#getRpcServiceOptions = getRpcServiceOptions; this.#getBlockTrackerOptions = getBlockTrackerOptions; @@ -2844,6 +2852,7 @@ export class NetworkController extends BaseController< ), ); + const defaultFailoverUrls = this.#failoverUrls?.[networkFields.chainId]; for (const addedRpcEndpoint of addedRpcEndpoints) { if (addedRpcEndpoint.type === RpcEndpointType.Infura) { autoManagedNetworkClientRegistry[NetworkClientType.Infura][ @@ -2854,7 +2863,8 @@ export class NetworkController extends BaseController< type: NetworkClientType.Infura, chainId: networkFields.chainId, network: addedRpcEndpoint.networkClientId, - failoverRpcUrls: addedRpcEndpoint.failoverUrls, + failoverRpcUrls: + defaultFailoverUrls ?? addedRpcEndpoint.failoverUrls, infuraProjectId: this.#infuraProjectId, ticker: networkFields.nativeCurrency, }, @@ -2872,7 +2882,8 @@ export class NetworkController extends BaseController< networkClientConfiguration: { type: NetworkClientType.Custom, chainId: networkFields.chainId, - failoverRpcUrls: addedRpcEndpoint.failoverUrls, + failoverRpcUrls: + defaultFailoverUrls ?? addedRpcEndpoint.failoverUrls, rpcUrl: addedRpcEndpoint.url, ticker: networkFields.nativeCurrency, }, @@ -3023,6 +3034,7 @@ export class NetworkController extends BaseController< const networkClientsWithIds = chainIds.flatMap((chainId) => { const networkConfiguration = this.state.networkConfigurationsByChainId[chainId]; + const defaultFailoverUrls = this.#failoverUrls?.[chainId]; return networkConfiguration.rpcEndpoints.map((rpcEndpoint) => { if (rpcEndpoint.type === RpcEndpointType.Infura) { const infuraNetworkName = deriveInfuraNetworkNameFromRpcEndpointUrl( @@ -3035,7 +3047,8 @@ export class NetworkController extends BaseController< networkClientConfiguration: { type: NetworkClientType.Infura, network: infuraNetworkName, - failoverRpcUrls: rpcEndpoint.failoverUrls, + failoverRpcUrls: + defaultFailoverUrls ?? rpcEndpoint.failoverUrls, infuraProjectId: this.#infuraProjectId, chainId: networkConfiguration.chainId, ticker: networkConfiguration.nativeCurrency, @@ -3055,7 +3068,7 @@ export class NetworkController extends BaseController< networkClientConfiguration: { type: NetworkClientType.Custom, chainId: networkConfiguration.chainId, - failoverRpcUrls: rpcEndpoint.failoverUrls, + failoverRpcUrls: defaultFailoverUrls ?? rpcEndpoint.failoverUrls, rpcUrl: rpcEndpoint.url, ticker: networkConfiguration.nativeCurrency, }, diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index 5cfe6692a4..e715e650aa 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -1539,6 +1539,115 @@ describe('NetworkController', () => { }); }); }); + + describe('if the controller was initialized with failoverUrls', () => { + it('applies the chain-level failover URLs to an Infura network client, overriding the endpoint value', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + infuraProjectId, + failoverUrls: { + [ChainId[InfuraNetworkType.mainnet]]: ['https://chain.failover'], + }, + }, + async ({ controller }) => { + const networkClient = controller.getNetworkClientById( + NetworkType.mainnet, + ); + + expect(networkClient.configuration).toStrictEqual({ + chainId: ChainId[InfuraNetworkType.mainnet], + failoverRpcUrls: ['https://chain.failover'], + infuraProjectId, + network: InfuraNetworkType.mainnet, + ticker: NetworksTicker[InfuraNetworkType.mainnet], + type: NetworkClientType.Infura, + }); + }, + ); + }); + + it('applies the chain-level failover URLs to a custom network client, overriding the endpoint value', async () => { + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + failoverUrls: ['https://endpoint.failover'], + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + }, + }), + infuraProjectId: 'some-infura-project-id', + failoverUrls: { + '0x1337': ['https://chain.failover'], + }, + }, + async ({ controller }) => { + const networkClient = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + + expect(networkClient.configuration).toStrictEqual({ + chainId: '0x1337', + failoverRpcUrls: ['https://chain.failover'], + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }); + }, + ); + }); + + it('falls back to the endpoint failover URLs when no entry exists for the chain', async () => { + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + failoverUrls: ['https://endpoint.failover'], + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + }, + }), + infuraProjectId: 'some-infura-project-id', + failoverUrls: { + '0x9999': ['https://chain.failover'], + }, + }, + async ({ controller }) => { + const networkClient = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + + expect(networkClient.configuration).toStrictEqual({ + chainId: '0x1337', + failoverRpcUrls: ['https://endpoint.failover'], + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }); + }, + ); + }); + }); }); describe('getNetworkClientRegistry', () => { @@ -1821,6 +1930,65 @@ describe('NetworkController', () => { ); }); }); + + describe('if the controller was initialized with failoverUrls', () => { + it('applies the chain-level failover URLs to every endpoint on a matched chain, keeping endpoint URLs for unmatched chains', async () => { + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN1', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + failoverUrls: ['https://first.endpoint.failover'], + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + failoverUrls: ['https://second.endpoint.failover'], + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }), + '0x2448': buildCustomNetworkConfiguration({ + chainId: '0x2448', + nativeCurrency: 'TOKEN2', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + failoverUrls: ['https://third.endpoint.failover'], + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + url: 'https://test.network/3', + }), + ], + }), + }, + }), + failoverUrls: { + '0x1337': ['https://chain.failover'], + }, + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + + const registry = controller.getNetworkClientRegistry(); + + expect( + registry['AAAA-AAAA-AAAA-AAAA'].configuration.failoverRpcUrls, + ).toStrictEqual(['https://chain.failover']); + expect( + registry['BBBB-BBBB-BBBB-BBBB'].configuration.failoverRpcUrls, + ).toStrictEqual(['https://chain.failover']); + expect( + registry['CCCC-CCCC-CCCC-CCCC'].configuration.failoverRpcUrls, + ).toStrictEqual(['https://third.endpoint.failover']); + }, + ); + }); + }); }); describe('lookupNetwork', () => { @@ -4472,6 +4640,90 @@ describe('NetworkController', () => { ); }); + it('overrides the per-endpoint failover URLs with the chain-level failoverUrls when the controller was initialized with them', async () => { + uuidV4Mock + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB') + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + ], + }), + }, + }), + infuraProjectId, + failoverUrls: { + [infuraChainId]: ['https://chain.failover'], + }, + isRpcFailoverEnabled: true, + }, + ({ controller }) => { + const defaultRpcEndpoint: InfuraRpcEndpoint = { + failoverUrls: ['https://first.failover.endpoint'], + name: infuraNetworkNickname, + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}` as const, + }; + + controller.addNetwork({ + blockExplorerUrls: [], + chainId: infuraChainId, + defaultRpcEndpointIndex: 1, + name: infuraNetworkType, + nativeCurrency: infuraNativeTokenName, + rpcEndpoints: [ + defaultRpcEndpoint, + { + failoverUrls: ['https://second.failover.endpoint'], + name: 'Test Network 1', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', + }, + ], + }); + + // Skipping the 1st call because it's for the initial state + expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + networkClientId: infuraNetworkType, + networkClientConfiguration: expect.objectContaining({ + failoverRpcUrls: ['https://chain.failover'], + type: NetworkClientType.Infura, + }), + }), + ); + expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkClientConfiguration: expect.objectContaining({ + failoverRpcUrls: ['https://chain.failover'], + rpcUrl: 'https://test.endpoint/2', + type: NetworkClientType.Custom, + }), + }), + ); + }, + ); + }); + it('adds the network configuration to state under the chain ID', async () => { uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); @@ -5837,6 +6089,87 @@ describe('NetworkController', () => { ); }); + it('overrides the endpoint failover URLs with the chain-level failoverUrls when the controller was initialized with them', async () => { + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.network', + }), + ], + }); + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + infuraProjectId, + failoverUrls: { + [infuraChainId]: ['https://chain.failover'], + }, + isRpcFailoverEnabled: true, + }, + async ({ controller }) => { + const infuraRpcEndpoint: InfuraRpcEndpoint = { + failoverUrls: ['https://failover.endpoint'], + networkClientId: infuraNetworkType, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, + type: RpcEndpointType.Infura, + }; + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + infuraRpcEndpoint, + ], + }); + + expect( + createAutoManagedNetworkClientSpy, + ).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + networkClientId: infuraNetworkType, + networkClientConfiguration: expect.objectContaining({ + failoverRpcUrls: ['https://chain.failover'], + type: NetworkClientType.Infura, + }), + }), + ); + + const networkConfigurationsByNetworkClientId = + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ); + expect( + networkConfigurationsByNetworkClientId[infuraNetworkType] + .failoverRpcUrls, + ).toStrictEqual(['https://chain.failover']); + }, + ); + }); + it('stores the network configuration with the new RPC endpoint in state', async () => { const networkConfigurationToUpdate = buildInfuraNetworkConfiguration(infuraNetworkType, { diff --git a/merged-packages/smart-transactions-controller/CHANGELOG.md b/packages/smart-transactions-controller/CHANGELOG.md similarity index 78% rename from merged-packages/smart-transactions-controller/CHANGELOG.md rename to packages/smart-transactions-controller/CHANGELOG.md index 21f7596c35..f1ee39a621 100644 --- a/merged-packages/smart-transactions-controller/CHANGELOG.md +++ b/packages/smart-transactions-controller/CHANGELOG.md @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- This package was migrated to the core monorepo from [`MetaMask/smart-transactions-controller`](https://github.com/MetaMask/smart-transactions-controller). For the changelog up to and including v24.2.1 (the last release from the original repo), see the [historical changelog](https://github.com/MetaMask/smart-transactions-controller/blob/v24.2.1/CHANGELOG.md). +### Changed + +- This package was migrated to the core monorepo from [`MetaMask/smart-transactions-controller`](https://github.com/MetaMask/smart-transactions-controller). For the changelog up to and including v24.2.1 (the last release from the original repo), see the [historical changelog](https://github.com/MetaMask/smart-transactions-controller/blob/v24.2.1/CHANGELOG.md) ([#9139](https://github.com/MetaMask/core/pull/9139)). + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/merged-packages/smart-transactions-controller/LICENSE b/packages/smart-transactions-controller/LICENSE similarity index 100% rename from merged-packages/smart-transactions-controller/LICENSE rename to packages/smart-transactions-controller/LICENSE diff --git a/packages/smart-transactions-controller/README.md b/packages/smart-transactions-controller/README.md new file mode 100644 index 0000000000..ec18943667 --- /dev/null +++ b/packages/smart-transactions-controller/README.md @@ -0,0 +1,62 @@ +# `@metamask/smart-transactions-controller` + +Improves success rates for swaps by trialing transactions privately and finding minimum fees. + +## Installation + +`yarn add @metamask/smart-transactions-controller` + +or + +`npm install @metamask/smart-transactions-controller` + +## Feature Flags + +Smart transactions feature flags are managed via `RemoteFeatureFlagController` (LaunchDarkly). The configuration uses a `default` remote object for global settings and chain-specific overrides keyed by hex chain ID. + +The flag in LaunchDarkly is named `smartTransactionsNetworks`. + +### Adding a New Flag + +1. **Add the field to the schema** in `src/utils/validators.ts`: + + ```typescript + export const SmartTransactionsNetworkConfigSchema = type({ + // ... existing fields + myNewFlag: optional(boolean()), + }); + ``` + + The `SmartTransactionsNetworkConfig` type is automatically inferred from this schema. + +2. **Add default value** in `src/constants.ts` under `DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS`: + + These values should be defensive. They are applied when the remote config is invalid or does not exist for a network. + It disables smart transaction. + + ```typescript + export const DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS = { + default: { + // ... existing defaults + myNewFlag: false, + }, + }; + ``` + +3. **Use in clients** via the exported selectors: + + ```typescript + import { selectSmartTransactionsFeatureFlagsForChain } from '@metamask/smart-transactions-controller'; + + const chainConfig = selectSmartTransactionsFeatureFlagsForChain( + state, + '0x1', + ); + if (chainConfig.myNewFlag) { + // Feature is enabled + } + ``` + +## 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/merged-packages/smart-transactions-controller/jest.config.js b/packages/smart-transactions-controller/jest.config.js similarity index 91% rename from merged-packages/smart-transactions-controller/jest.config.js rename to packages/smart-transactions-controller/jest.config.js index 06c4cd8d64..f4ec849294 100644 --- a/merged-packages/smart-transactions-controller/jest.config.js +++ b/packages/smart-transactions-controller/jest.config.js @@ -23,8 +23,4 @@ module.exports = merge(baseConfig, { statements: 91.89, }, }, - - setupFiles: ['./setupJest.js'], - - testTimeout: 2500, }); diff --git a/merged-packages/smart-transactions-controller/package.json b/packages/smart-transactions-controller/package.json similarity index 93% rename from merged-packages/smart-transactions-controller/package.json rename to packages/smart-transactions-controller/package.json index b8a2e3bb6f..4d23086f8d 100644 --- a/merged-packages/smart-transactions-controller/package.json +++ b/packages/smart-transactions-controller/package.json @@ -1,7 +1,7 @@ { "name": "@metamask/smart-transactions-controller", "version": "24.2.1", - "description": "Improves success rates for swaps by trialing transactions privately and finding minimum fees.", + "description": "Improves success rates for swaps by trialing transactions privately and finding minimum fees", "keywords": [ "Ethereum", "MetaMask" @@ -15,7 +15,12 @@ "type": "git", "url": "https://github.com/MetaMask/core.git" }, + "files": [ + "dist/" + ], "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", "exports": { ".": { "import": { @@ -29,11 +34,10 @@ }, "./package.json": "./package.json" }, - "main": "./dist/index.cjs", - "types": "./dist/index.d.cts", - "files": [ - "dist/" - ], + "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", @@ -50,8 +54,6 @@ }, "dependencies": { "@babel/runtime": "^7.23.9", - "@ethereumjs/tx": "^5.4.0", - "@ethereumjs/util": "^9.1.0", "@ethersproject/bytes": "^5.7.0", "@ethersproject/keccak256": "^5.8.0", "@ethersproject/transactions": "^5.7.0", @@ -68,28 +70,22 @@ "@metamask/transaction-controller": "^68.0.0", "@metamask/utils": "^11.11.0", "bignumber.js": "^9.1.2", - "fast-json-patch": "^3.1.1", "lodash": "^4.17.21", "reselect": "^5.1.1" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", - "@metamask/gas-fee-controller": "^26.2.2", "@metamask/json-rpc-engine": "^10.5.0", "@ts-bridge/cli": "^0.6.4", "deepmerge": "^4.2.2", "jest": "^29.7.0", - "nock": "^14.0.0-beta.7", + "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" }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - }, "engines": { "node": "^18.18 || >=20" } diff --git a/merged-packages/smart-transactions-controller/src/SmartTransactionsController-method-action-types.ts b/packages/smart-transactions-controller/src/SmartTransactionsController-method-action-types.ts similarity index 100% rename from merged-packages/smart-transactions-controller/src/SmartTransactionsController-method-action-types.ts rename to packages/smart-transactions-controller/src/SmartTransactionsController-method-action-types.ts diff --git a/merged-packages/smart-transactions-controller/src/SmartTransactionsController.test.ts b/packages/smart-transactions-controller/src/SmartTransactionsController.test.ts similarity index 100% rename from merged-packages/smart-transactions-controller/src/SmartTransactionsController.test.ts rename to packages/smart-transactions-controller/src/SmartTransactionsController.test.ts index 0ec5a462ee..8b2b2fdf5c 100644 --- a/merged-packages/smart-transactions-controller/src/SmartTransactionsController.test.ts +++ b/packages/smart-transactions-controller/src/SmartTransactionsController.test.ts @@ -26,6 +26,7 @@ import type { Hex } from '@metamask/utils'; import nock from 'nock'; import packageJson from '../package.json'; +import { advanceTime, flushPromises, getFakeProvider } from '../tests/helpers'; import { API_BASE_URL, SENTINEL_API_BASE_URL_MAP, @@ -40,7 +41,6 @@ import type { SmartTransactionsControllerMessenger } from './SmartTransactionsCo import type { SmartTransaction, UnsignedTransaction } from './types'; import { SmartTransactionStatuses, ClientId } from './types'; import * as utils from './utils'; -import { advanceTime, flushPromises, getFakeProvider } from '../tests/helpers'; type AllActions = MessengerActions; diff --git a/merged-packages/smart-transactions-controller/src/SmartTransactionsController.ts b/packages/smart-transactions-controller/src/SmartTransactionsController.ts similarity index 100% rename from merged-packages/smart-transactions-controller/src/SmartTransactionsController.ts rename to packages/smart-transactions-controller/src/SmartTransactionsController.ts diff --git a/merged-packages/smart-transactions-controller/src/constants.ts b/packages/smart-transactions-controller/src/constants.ts similarity index 97% rename from merged-packages/smart-transactions-controller/src/constants.ts rename to packages/smart-transactions-controller/src/constants.ts index 8e0c23e73f..236bd0c23b 100644 --- a/merged-packages/smart-transactions-controller/src/constants.ts +++ b/packages/smart-transactions-controller/src/constants.ts @@ -24,7 +24,6 @@ export enum MetaMetricsEventName { export enum MetaMetricsEventCategory { Transactions = 'Transactions', - // eslint-disable-next-line @typescript-eslint/no-shadow Navigation = 'Navigation', } diff --git a/merged-packages/smart-transactions-controller/src/featureFlags/feature-flags.test.ts b/packages/smart-transactions-controller/src/featureFlags/feature-flags.test.ts similarity index 100% rename from merged-packages/smart-transactions-controller/src/featureFlags/feature-flags.test.ts rename to packages/smart-transactions-controller/src/featureFlags/feature-flags.test.ts diff --git a/merged-packages/smart-transactions-controller/src/featureFlags/feature-flags.ts b/packages/smart-transactions-controller/src/featureFlags/feature-flags.ts similarity index 100% rename from merged-packages/smart-transactions-controller/src/featureFlags/feature-flags.ts rename to packages/smart-transactions-controller/src/featureFlags/feature-flags.ts diff --git a/merged-packages/smart-transactions-controller/src/featureFlags/index.ts b/packages/smart-transactions-controller/src/featureFlags/index.ts similarity index 100% rename from merged-packages/smart-transactions-controller/src/featureFlags/index.ts rename to packages/smart-transactions-controller/src/featureFlags/index.ts diff --git a/merged-packages/smart-transactions-controller/src/featureFlags/validators.test.ts b/packages/smart-transactions-controller/src/featureFlags/validators.test.ts similarity index 100% rename from merged-packages/smart-transactions-controller/src/featureFlags/validators.test.ts rename to packages/smart-transactions-controller/src/featureFlags/validators.test.ts diff --git a/merged-packages/smart-transactions-controller/src/featureFlags/validators.ts b/packages/smart-transactions-controller/src/featureFlags/validators.ts similarity index 100% rename from merged-packages/smart-transactions-controller/src/featureFlags/validators.ts rename to packages/smart-transactions-controller/src/featureFlags/validators.ts diff --git a/merged-packages/smart-transactions-controller/src/index.ts b/packages/smart-transactions-controller/src/index.ts similarity index 100% rename from merged-packages/smart-transactions-controller/src/index.ts rename to packages/smart-transactions-controller/src/index.ts diff --git a/merged-packages/smart-transactions-controller/src/selectors.test.ts b/packages/smart-transactions-controller/src/selectors.test.ts similarity index 100% rename from merged-packages/smart-transactions-controller/src/selectors.test.ts rename to packages/smart-transactions-controller/src/selectors.test.ts diff --git a/merged-packages/smart-transactions-controller/src/selectors.ts b/packages/smart-transactions-controller/src/selectors.ts similarity index 100% rename from merged-packages/smart-transactions-controller/src/selectors.ts rename to packages/smart-transactions-controller/src/selectors.ts diff --git a/merged-packages/smart-transactions-controller/src/types.ts b/packages/smart-transactions-controller/src/types.ts similarity index 100% rename from merged-packages/smart-transactions-controller/src/types.ts rename to packages/smart-transactions-controller/src/types.ts diff --git a/merged-packages/smart-transactions-controller/src/utils.test.ts b/packages/smart-transactions-controller/src/utils.test.ts similarity index 100% rename from merged-packages/smart-transactions-controller/src/utils.test.ts rename to packages/smart-transactions-controller/src/utils.test.ts diff --git a/merged-packages/smart-transactions-controller/src/utils.ts b/packages/smart-transactions-controller/src/utils.ts similarity index 99% rename from merged-packages/smart-transactions-controller/src/utils.ts rename to packages/smart-transactions-controller/src/utils.ts index 2e2f61cb48..533c732b4d 100644 --- a/merged-packages/smart-transactions-controller/src/utils.ts +++ b/packages/smart-transactions-controller/src/utils.ts @@ -131,6 +131,7 @@ export const calculateStatus = (stxStatus: SmartTransactionsStatus) => { /** * Returns processing time for an STX in seconds. + * * @param smartTransactionSubmittedtime * @returns Processing time in seconds. */ diff --git a/merged-packages/smart-transactions-controller/tests/helpers.ts b/packages/smart-transactions-controller/tests/helpers.ts similarity index 99% rename from merged-packages/smart-transactions-controller/tests/helpers.ts rename to packages/smart-transactions-controller/tests/helpers.ts index ce8acfb966..31f81e95f0 100644 --- a/merged-packages/smart-transactions-controller/tests/helpers.ts +++ b/packages/smart-transactions-controller/tests/helpers.ts @@ -17,6 +17,7 @@ export const flushPromises = async () => { * promises or other asynchronous operations that may get enqueued during the timer's duration. * By advancing time in incremental steps and flushing promises between each step, * this function ensures that both timers and promises are comprehensively processed. + * * @param options - The options object. * @param options.duration - The total amount of time (in milliseconds) to advance the timer by. * @param options.stepSize - The incremental step size (in milliseconds) by which the timer is advanced in each iteration. Default is 1/4 of the duration. diff --git a/merged-packages/smart-transactions-controller/tsconfig.build.json b/packages/smart-transactions-controller/tsconfig.build.json similarity index 100% rename from merged-packages/smart-transactions-controller/tsconfig.build.json rename to packages/smart-transactions-controller/tsconfig.build.json diff --git a/merged-packages/smart-transactions-controller/tsconfig.json b/packages/smart-transactions-controller/tsconfig.json similarity index 100% rename from merged-packages/smart-transactions-controller/tsconfig.json rename to packages/smart-transactions-controller/tsconfig.json diff --git a/merged-packages/smart-transactions-controller/typedoc.json b/packages/smart-transactions-controller/typedoc.json similarity index 100% rename from merged-packages/smart-transactions-controller/typedoc.json rename to packages/smart-transactions-controller/typedoc.json diff --git a/packages/social-controllers/CHANGELOG.md b/packages/social-controllers/CHANGELOG.md index 1f90180aa1..12e08b6f8d 100644 --- a/packages/social-controllers/CHANGELOG.md +++ b/packages/social-controllers/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.3.0] + +### Added + +- Add optional perp fields to the `Trade` type (and `TradeStruct`): `classification` (`'spot' | 'perp' | 'send' | 'receive' | null`), `perpPositionType` (`'long' | 'short' | null`), and `perpLeverage` (`number | null`) — exposing Hyperliquid/perp trade metadata to consumers ([#9094](https://github.com/MetaMask/core/pull/9094)) +- Add optional perp fields to the `Position` type (and `PositionStruct`): `perpPositionType` (`'long' | 'short' | null`), `perpLeverage` (`number | null`), and `positionAmountWithLeverage` (`number | null`) — exposing Hyperliquid/perp position metadata to consumers ([#9094](https://github.com/MetaMask/core/pull/9094)) + ### Changed - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.2.0` ([#8774](https://github.com/MetaMask/core/pull/8774), [#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083)) @@ -86,7 +93,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `unfollowTrader` — unfollows traders and removes addresses from state - `updateFollowing` — fetches following list and replaces addresses in state -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/social-controllers@2.2.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/social-controllers@2.3.0...HEAD +[2.3.0]: https://github.com/MetaMask/core/compare/@metamask/social-controllers@2.2.1...@metamask/social-controllers@2.3.0 [2.2.1]: https://github.com/MetaMask/core/compare/@metamask/social-controllers@2.2.0...@metamask/social-controllers@2.2.1 [2.2.0]: https://github.com/MetaMask/core/compare/@metamask/social-controllers@2.1.0...@metamask/social-controllers@2.2.0 [2.1.0]: https://github.com/MetaMask/core/compare/@metamask/social-controllers@2.0.0...@metamask/social-controllers@2.1.0 diff --git a/packages/social-controllers/package.json b/packages/social-controllers/package.json index e78af3281a..cdc681f86e 100644 --- a/packages/social-controllers/package.json +++ b/packages/social-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/social-controllers", - "version": "2.2.1", + "version": "2.3.0", "description": "A collection of social related controllers", "keywords": [ "Ethereum", diff --git a/packages/social-controllers/src/SocialService.test.ts b/packages/social-controllers/src/SocialService.test.ts index 43defcc5fc..4ddf666f2e 100644 --- a/packages/social-controllers/src/SocialService.test.ts +++ b/packages/social-controllers/src/SocialService.test.ts @@ -53,6 +53,37 @@ const mockPosition = { tokenImageUrl: 'https://assets.daylight.xyz/images/token-eth.png', }; +const mockPerpTrade = { + direction: 'buy', + intent: 'enter', + classification: 'perp', + perpPositionType: 'long', + perpLeverage: 10, + tokenAmount: 1.5, + usdCost: 3000, + timestamp: 1700000000, + transactionHash: + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', +}; + +const mockPerpPosition = { + positionId: 'position-perp-1', + tokenSymbol: 'BTC', + tokenName: 'Bitcoin', + tokenAddress: 'BTC', + chain: 'hyperliquid', + positionAmount: 2.5, + boughtUsd: 112500, + soldUsd: 0, + realizedPnl: 0, + costBasis: 112500, + trades: [mockPerpTrade], + lastTradeAt: 1700000000, + perpPositionType: 'long', + perpLeverage: 10, + positionAmountWithLeverage: 25, +}; + const MOCK_TOKEN = 'mock-bearer-token'; type RootMessenger = Messenger< @@ -497,6 +528,86 @@ describe('SocialService', () => { SocialServiceErrorMessage.FETCH_OPEN_POSITIONS_INVALID_RESPONSE, ); }); + + it('passes through perp metadata on positions and trades', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + positions: [mockPerpPosition], + pagination: { hasMore: false }, + }), + }); + + const service = createService(); + const result = await service.fetchOpenPositions({ + addressOrId: '0x1234', + }); + + expect(result.positions[0]).toStrictEqual(mockPerpPosition); + expect(result.positions[0].perpPositionType).toBe('long'); + expect(result.positions[0].perpLeverage).toBe(10); + expect(result.positions[0].positionAmountWithLeverage).toBe(25); + expect(result.positions[0].trades[0].classification).toBe('perp'); + expect(result.positions[0].trades[0].perpPositionType).toBe('long'); + expect(result.positions[0].trades[0].perpLeverage).toBe(10); + }); + + it('accepts null perp fields for spot positions', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + positions: [ + { + ...mockPosition, + perpPositionType: null, + perpLeverage: null, + positionAmountWithLeverage: null, + trades: [ + { + ...mockTrade, + classification: null, + perpPositionType: null, + perpLeverage: null, + }, + ], + }, + ], + pagination: { hasMore: false }, + }), + }); + + const service = createService(); + const result = await service.fetchOpenPositions({ + addressOrId: '0x1234', + }); + + expect(result.positions[0].perpPositionType).toBeNull(); + expect(result.positions[0].trades[0].classification).toBeNull(); + }); + + it('rejects an invalid perpPositionType', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + positions: [{ ...mockPerpPosition, perpPositionType: 'sideways' }], + pagination: { hasMore: false }, + }), + }); + + const service = createService(); + + await expect( + service.fetchOpenPositions({ addressOrId: '0x1234' }), + ).rejects.toThrow( + SocialServiceErrorMessage.FETCH_OPEN_POSITIONS_INVALID_RESPONSE, + ); + }); }); describe('fetchClosedPositions', () => { diff --git a/packages/social-controllers/src/SocialService.ts b/packages/social-controllers/src/SocialService.ts index 2442c37ecf..3401fe7bf1 100644 --- a/packages/social-controllers/src/SocialService.ts +++ b/packages/social-controllers/src/SocialService.ts @@ -11,6 +11,7 @@ import type { AuthenticationController } from '@metamask/profile-sync-controller import { array, boolean, + enums, is, nullable, number, @@ -76,6 +77,9 @@ const PositionStruct = structType({ currentValueUSD: optional(nullable(number())), pnlValueUsd: optional(nullable(number())), pnlPercent: optional(nullable(number())), + perpPositionType: optional(nullable(enums(['long', 'short']))), + perpLeverage: optional(nullable(number())), + positionAmountWithLeverage: optional(nullable(number())), }); const PaginationStruct = structType({ diff --git a/packages/social-controllers/src/social-types.ts b/packages/social-controllers/src/social-types.ts index c7de2384fb..f9e5a57158 100644 --- a/packages/social-controllers/src/social-types.ts +++ b/packages/social-controllers/src/social-types.ts @@ -1,6 +1,7 @@ import type { Infer } from '@metamask/superstruct'; import { enums, + nullable, number, optional, string, @@ -39,6 +40,14 @@ export const TradeStruct = structType({ direction: enums(['buy', 'sell']), intent: enums(['enter', 'exit']), category: optional(string()), + /** High-level trade classification. `null` when Clicker does not classify. */ + classification: optional( + nullable(enums(['spot', 'perp', 'send', 'receive'])), + ), + /** Perp side for this fill. `null` for spot trades. */ + perpPositionType: optional(nullable(enums(['long', 'short']))), + /** Leverage multiplier for perp trades (e.g. `5` for 5x). `null` for spot. */ + perpLeverage: optional(nullable(number())), tokenAmount: number(), usdCost: number(), timestamp: number(), @@ -153,6 +162,19 @@ export type Position = { pnlValueUsd?: number | null; /** PnL as a percentage of cost basis. */ pnlPercent?: number | null; + /** Perp side of the position. `null`/absent for spot positions. */ + perpPositionType?: 'long' | 'short' | null; + /** Leverage multiplier for perp positions. `null`/absent for spot. */ + perpLeverage?: number | null; + /** + * Leveraged/notional position size as reported by Clicker. NOT necessarily + * `positionAmount` × `perpLeverage` — the ratio varies for positions built + * across fills at different leverage, so use this field directly rather than + * deriving it, and treat `perpLeverage` as the authoritative leverage. This is + * notional exposure, not capital at risk (the margin/capital at risk is + * `costBasis`). Hyperliquid/perp positions only; absent for spot. + */ + positionAmountWithLeverage?: number | null; }; export type Pagination = { diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 9bc8d7c228..17116e6f03 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,10 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [23.8.0] + ### Changed - Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.0` ([#9129](https://github.com/MetaMask/core/pull/9129)) +### Fixed + +- Mark MM Pay transactions as externally signed when quotes are available ([#9145](https://github.com/MetaMask/core/pull/9145)) + ## [23.7.0] ### Added @@ -1078,7 +1084,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#6820](https://github.com/MetaMask/core/pull/6820)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@23.7.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@23.8.0...HEAD +[23.8.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@23.7.0...@metamask/transaction-pay-controller@23.8.0 [23.7.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@23.6.0...@metamask/transaction-pay-controller@23.7.0 [23.6.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@23.5.1...@metamask/transaction-pay-controller@23.6.0 [23.5.1]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@23.5.0...@metamask/transaction-pay-controller@23.5.1 diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index a88319b9f6..22b6e546b0 100644 --- a/packages/transaction-pay-controller/package.json +++ b/packages/transaction-pay-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-pay-controller", - "version": "23.7.0", + "version": "23.8.0", "description": "Manages alternate payment strategies to provide required funds for transactions in MetaMask", "keywords": [ "Ethereum", diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index 937b03235e..69fe267549 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -730,6 +730,30 @@ describe('Quotes Utils', () => { ); }); + it('marks the transaction as externally signed when quotes are available so the publish hook owns submission', async () => { + await run(); + + const transactionMetaMock = {} as TransactionMeta; + updateTransactionMock.mock.calls[0][1](transactionMetaMock); + + expect(transactionMetaMock).toMatchObject( + expect.objectContaining({ isExternalSign: true }), + ); + }); + + it('clears the externally signed flag when no quotes are returned so the transaction falls back to local signing', async () => { + getQuotesMock.mockResolvedValue([]); + + await run(); + + const transactionMetaMock = {} as TransactionMeta; + updateTransactionMock.mock.calls[0][1](transactionMetaMock); + + expect(transactionMetaMock).toMatchObject( + expect.objectContaining({ isExternalSign: false }), + ); + }); + it('updates metrics in metadata', async () => { await run(); diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index 319bbbc71c..098911a2de 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -163,6 +163,7 @@ export async function updateQuotes( syncTransaction({ batchTransactions, + hasQuotes: quotes.length > 0, isPostQuote, messenger: messenger as never, paymentToken, @@ -198,6 +199,7 @@ export async function updateQuotes( * * @param request - Request object. * @param request.batchTransactions - Batch transactions to sync. + * @param request.hasQuotes - Whether MM Pay produced any quotes for this transaction. * @param request.isPostQuote - Whether this is a post-quote flow. * @param request.messenger - Messenger instance. * @param request.paymentToken - Payment token (source for standard flows, destination for post-quote). @@ -206,6 +208,7 @@ export async function updateQuotes( */ function syncTransaction({ batchTransactions, + hasQuotes, isPostQuote, messenger, paymentToken, @@ -213,6 +216,7 @@ function syncTransaction({ transactionId, }: { batchTransactions: BatchTransaction[]; + hasQuotes: boolean; isPostQuote?: boolean; messenger: TransactionPayControllerMessenger; paymentToken: TransactionPaymentToken | undefined; @@ -233,6 +237,14 @@ function syncTransaction({ tx.batchTransactions = batchTransactions; tx.batchTransactionsOptions = {}; + // When MM Pay has produced quotes, it owns submission of this transaction + // via its strategy publish hook, so the parent must be marked externally + // signed to skip the local `KeyringController:signTransaction` call. + // When there are no quotes (e.g. user selected the target token as the + // payment token in a Predict flow), the transaction falls back to normal + // local signing, so the flag is cleared to allow that. + tx.isExternalSign = hasQuotes; + tx.metamaskPay = { bridgeFeeFiat: totals.fees.provider.usd, chainId: paymentToken.chainId, diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index 455dbb1d4a..587b49c08f 100644 --- a/packages/wallet-cli/CHANGELOG.md +++ b/packages/wallet-cli/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add SQLite-backed persistence for wallet controller state ([#9067](https://github.com/MetaMask/core/pull/9067)) + - A `KeyValueStore` backed by `better-sqlite3` for synchronous reads and writes. + - `loadState` to rehydrate persist-flagged controller state from the store and `subscribeToChanges` to write persist-flagged controller state through to disk on every `stateChanged` event. - Initial package scaffold for `@metamask/wallet-cli`, an [oclif](https://oclif.io)-based `mm` CLI for `@metamask/wallet` ([#9065](https://github.com/MetaMask/core/pull/9065)). [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/wallet-cli/README.md b/packages/wallet-cli/README.md index 1de99a32fa..ddb25c377d 100644 --- a/packages/wallet-cli/README.md +++ b/packages/wallet-cli/README.md @@ -10,6 +10,24 @@ or `npm install @metamask/wallet-cli` +## Troubleshooting + +### Rebuilding `better-sqlite3` + +This package depends on `better-sqlite3`, which ships a native C addon. The monorepo runs Yarn with `enableScripts: false`, so the addon is **not** fetched automatically during `yarn install`. Instead, the package's `test:prepare` script (`scripts/install-binaries.sh`) downloads the matching prebuild on demand the first time you run tests, falling back to compiling the addon from source (via `node-gyp`) when no prebuild is published for your Node ABI/platform. + +If you switch Node versions or branches and the binding is missing, re-run: + +```sh +yarn workspace @metamask/wallet-cli run test:prepare +``` + +Or invoke `prebuild-install` directly from the monorepo root (where `better-sqlite3` is hoisted): + +```sh +cd node_modules/better-sqlite3 && node ../.bin/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-cli/package.json b/packages/wallet-cli/package.json index a1a88bf3df..aa5d527127 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -36,17 +36,24 @@ "changelog:update": "../../scripts/update-changelog.sh @metamask/wallet-cli", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/wallet-cli", "since-latest-release": "../../scripts/since-latest-release.sh", - "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "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": { - "@oclif/core": "^4.10.5" + "@metamask/base-controller": "^9.1.0", + "@metamask/utils": "^11.11.0", + "@metamask/wallet": "^3.0.0", + "@oclif/core": "^4.10.5", + "better-sqlite3": "^12.9.0", + "immer": "^9.0.6" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", "@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", @@ -60,6 +67,6 @@ "topicSeparator": " " }, "engines": { - "node": "^18.18 || >=20" + "node": ">=20" } } diff --git a/packages/wallet-cli/scripts/install-binaries.sh b/packages/wallet-cli/scripts/install-binaries.sh new file mode 100755 index 0000000000..79a7f88bbf --- /dev/null +++ b/packages/wallet-cli/scripts/install-binaries.sh @@ -0,0 +1,28 @@ +#!/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}" + +# 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. Reproduce +# better-sqlite3's own install step (`prebuild-install || node-gyp rebuild +# --release`): fetch a matching prebuild for the active Node version and +# platform, and fall back to compiling from source when no prebuild is +# published for that ABI/libc combination (e.g. some Linux CI runners). +BETTER_SQLITE3_DIR="${MONOREPO_ROOT}/node_modules/better-sqlite3" +if [ ! -f "${BETTER_SQLITE3_DIR}/build/Release/better_sqlite3.node" ]; then + ( + cd "${BETTER_SQLITE3_DIR}" + if ! "${MONOREPO_ROOT}/node_modules/.bin/prebuild-install"; then + echo "wallet-cli: prebuild-install failed (see its output above); compiling better-sqlite3 from source. This needs a C/C++ toolchain and Python." >&2 + "${MONOREPO_ROOT}/node_modules/.bin/node-gyp" rebuild --release + fi + ) +fi diff --git a/packages/wallet-cli/src/persistence/KeyValueStore.test.ts b/packages/wallet-cli/src/persistence/KeyValueStore.test.ts new file mode 100644 index 0000000000..f7c3ad6445 --- /dev/null +++ b/packages/wallet-cli/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-cli/src/persistence/KeyValueStore.ts b/packages/wallet-cli/src/persistence/KeyValueStore.ts new file mode 100644 index 0000000000..b7b0d65459 --- /dev/null +++ b/packages/wallet-cli/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-cli/src/persistence/persistence.test.ts b/packages/wallet-cli/src/persistence/persistence.test.ts new file mode 100644 index 0000000000..7d26e5520c --- /dev/null +++ b/packages/wallet-cli/src/persistence/persistence.test.ts @@ -0,0 +1,606 @@ +import type { StateMetadataConstraint } from '@metamask/base-controller'; +import type { Json } from '@metamask/utils'; +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '@metamask/wallet'; + +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]); + + const controllerMetadata = createControllerMetadata({ + ControllerA: [ + ['prop1', true], + ['prop2', true], + ], + ControllerB: [['prop1', true]], + }); + + expect(loadState(store, controllerMetadata)).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'); + + const controllerMetadata = createControllerMetadata({ + Controller: [['prop.with.dots', true]], + }); + + expect(loadState(store, controllerMetadata)).toStrictEqual({ + Controller: { 'prop.with.dots': 'value' }, + }); + }); + + it('rehydrates properties whose persist flag is a deriver function', () => { + store.set('TestController.derived', 'value'); + + const controllerMetadata = createControllerMetadata({ + TestController: [['derived', (value: never): Json => value]], + }); + + expect(loadState(store, controllerMetadata)).toStrictEqual({ + TestController: { derived: 'value' }, + }); + }); + + it('skips properties whose persist flag is disabled', () => { + store.set('TestController.kept', 'keepMe'); + store.set('TestController.dropped', 'staleValue'); + + const controllerMetadata = createControllerMetadata({ + TestController: [ + ['kept', true], + ['dropped', false], + ], + }); + + expect(loadState(store, controllerMetadata)).toStrictEqual({ + TestController: { kept: 'keepMe' }, + }); + }); + + it('skips properties absent from the controller metadata', () => { + store.set('TestController.kept', 'keepMe'); + store.set('TestController.removed', 'staleValue'); + + const controllerMetadata = createControllerMetadata({ + TestController: [['kept', true]], + }); + + expect(loadState(store, controllerMetadata)).toStrictEqual({ + TestController: { kept: 'keepMe' }, + }); + }); + + it('skips keys for controllers absent from the metadata', () => { + store.set('RemovedController.prop', 'staleValue'); + + expect(loadState(store, {})).toStrictEqual({}); + }); + + 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('logs and skips the write when a deriver result serializes to undefined', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([ + ['derived', (): Json => undefined as unknown as Json], + ]), + }); + + const log = jest.fn(); + subscribeToChanges(messenger, controllerMetadata, store, log); + + publishStateChanged(messenger, 'TestController', { + state: { derived: 'anything' }, + patches: [{ op: 'replace', path: ['derived'], value: 'anything' }], + }); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to persist state for TestController.derived', + ), + ); + expect(store.get('TestController.derived')).toBeUndefined(); + }); + + it('does not swallow errors thrown by a deriver function', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([ + [ + 'derived', + (): Json => { + throw new Error('deriver boom'); + }, + ], + ]), + }); + + const log = jest.fn(); + subscribeToChanges(messenger, controllerMetadata, store, log); + + expect(() => + publishStateChanged(messenger, 'TestController', { + state: { derived: 'value' }, + patches: [{ op: 'replace', path: ['derived'], value: 'value' }], + }), + ).toThrow('deriver boom'); + + expect(log).not.toHaveBeenCalled(); + }); + + 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('routes store.set failures through the supplied log callback', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([ + ['propA', true], + ['propB', true], + ]), + }); + + const log = jest.fn(); + subscribeToChanges(messenger, controllerMetadata, store, log); + + 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); + }); + + publishStateChanged(messenger, 'TestController', { + state: { propA: 'a', propB: 'b' }, + patches: [ + { op: 'replace', path: ['propA'], value: 'a' }, + { op: 'replace', path: ['propB'], value: 'b' }, + ], + }); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to persist state for TestController.propA', + ), + ); + // propB should still be persisted despite propA failing + expect(store.get('TestController.propB')).toBe('b'); + }); + + it('falls back to console.error when no log callback is supplied', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['prop', true]]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + const error = new Error('disk full'); + jest.spyOn(store, 'set').mockImplementationOnce(() => { + throw error; + }); + + publishStateChanged(messenger, 'TestController', { + state: { prop: 'value' }, + patches: [{ op: 'replace', path: ['prop'], value: 'value' }], + }); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to persist state for TestController.prop', + ), + ); + + 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('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, + }, + ]), + ); +} + +/** + * Builds a `controllerMetadata` map for `loadState` tests. + * + * @param controllers - Map of controller names to an array of + * [property name, persist value] pairs. + * @returns A `controllerMetadata` map keyed by controller name. + */ +function createControllerMetadata( + controllers: Record Json)][]>, +): Record { + return Object.fromEntries( + Object.entries(controllers).map(([name, properties]) => [ + name, + createStateMetadata(properties), + ]), + ); +} + +/** + * 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-cli/src/persistence/persistence.ts b/packages/wallet-cli/src/persistence/persistence.ts new file mode 100644 index 0000000000..ad9d3be64b --- /dev/null +++ b/packages/wallet-cli/src/persistence/persistence.ts @@ -0,0 +1,269 @@ +import type { StateMetadataConstraint } from '@metamask/base-controller'; +import { hasProperty } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '@metamask/wallet'; +import type { Patch } from 'immer'; + +import type { KeyValueStore } from './KeyValueStore'; + +/** + * Handler for a controller's `stateChanged` event: the new controller state and + * the Immer patches describing what changed. + */ +type StateChangedHandler = ( + state: Record, + patches: Patch[], +) => void; + +/** + * 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 } }`. + * + * Only properties that are currently persist-flagged in `controllerMetadata` + * are rehydrated. Rows for controllers or properties that no longer exist — or + * whose `persist` flag has since been disabled — are ignored. This keeps + * loading symmetric with {@link subscribeToChanges}, which only ever writes + * persist-flagged properties: without the filter, a migration that stops + * persisting a property would leave its stale row on disk to be resurrected + * into the `Wallet` constructor state on the next restart. + * + * @param store - The key-value store to read from. + * @param controllerMetadata - A map from controller name to its state metadata, + * used to filter out keys that are no longer persist-flagged. + * @returns A record of controller states, keyed by controller name, suitable + * for the `state` option of the `Wallet` constructor. + */ +export function loadState( + store: KeyValueStore, + controllerMetadata: Readonly< + Record> + >, +): 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 (!isPersisted(controllerMetadata[controllerName], propertyName)) { + continue; + } + + 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. + * + * @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. + * @param log - Optional logger for persistence-write failures. Defaults to + * `console.error` when omitted. A daemon host should supply its own logger, + * since a backgrounded daemon's stdio may be discarded. + * @returns A function that unsubscribes all persistence handlers. + */ +export function subscribeToChanges( + messenger: RootMessenger, + controllerMetadata: Readonly< + Record> + >, + store: KeyValueStore, + log?: (message: string) => void, +): () => void { + const unsubscribers: (() => void)[] = []; + const logFn = + log ?? + ((message: string): void => { + console.error(message); + }); + + for (const [controllerName, metadata] of Object.entries(controllerMetadata)) { + const persistedProperties = getPersistPropertyNames(metadata); + if (persistedProperties.size === 0) { + continue; + } + + const eventType = `${controllerName}:stateChanged`; + + const handler: StateChangedHandler = (state, patches) => { + const changed = getChangedProperties(patches, persistedProperties); + + for (const prop of changed) { + const key = storeKey(controllerName, prop); + const removed = !hasProperty(state, prop); + + // Derive the value before the try/catch so a throwing `StateDeriver` + // surfaces as its own error instead of a misreported write failure. + const persistFlag = metadata[prop]?.persist; + const value = + !removed && typeof persistFlag === 'function' + ? persistFlag(state[prop] as never) + : state[prop]; + + try { + if (removed) { + store.delete(key); + } else { + store.set(key, value); + } + } catch (error) { + // TODO: Surface persistence-write failures up the stack so callers + // can decide to halt rather than continue with diverging in-memory + // and on-disk state. For now, log and continue. + logFn(`Failed to persist state for ${key}: ${String(error)}`); + } + } + }; + + unsubscribers.push(subscribeToStateChanged(messenger, eventType, handler)); + } + + const unsubscribeAll = (): void => { + while (unsubscribers.length > 0) { + unsubscribers.pop()?.(); + } + }; + + return unsubscribeAll; +} + +/** + * Subscribe a handler to a controller's `stateChanged` event. + * + * The event name is built from a runtime controller name, so it widens to + * `string` and cannot be proven to be a literal member of the messenger's event + * union at compile time. This helper localizes that single unavoidable cast + * behind a typed {@link StateChangedHandler}, so the `(state, patches)` payload + * shape stays compile-checked at every call site instead of being erased by a + * statement-level `@ts-expect-error`. + * + * @param messenger - The root messenger to subscribe on. + * @param eventType - The `${controllerName}:stateChanged` event name. + * @param handler - The state-change handler to register. + * @returns A function that unsubscribes the handler. + */ +function subscribeToStateChanged( + messenger: RootMessenger, + eventType: string, + handler: StateChangedHandler, +): () => void { + const subscriber = messenger as unknown as { + subscribe: (eventType: string, handler: StateChangedHandler) => void; + unsubscribe: (eventType: string, handler: StateChangedHandler) => void; + }; + subscriber.subscribe(eventType, handler); + return () => { + subscriber.unsubscribe(eventType, handler); + }; +} + +/** + * Determine whether a property is currently persist-flagged. + * + * The `persist` flag is truthy when it is `true` or a `StateDeriver` function, + * and falsy when it is `false` or when the controller or property is absent + * from the metadata. `loadState` and `subscribeToChanges` share this predicate + * so the read and write paths can never disagree on what counts as persisted. + * + * @param metadata - The controller's state metadata, or `undefined` when the + * controller is absent from the metadata map. + * @param propertyName - The property name to check. + * @returns `true` if the property should be persisted. + */ +function isPersisted( + metadata: Readonly | undefined, + propertyName: string, +): boolean { + return Boolean(metadata?.[propertyName]?.persist); +} + +/** + * 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 (isPersisted(metadata, key)) { + 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-cli/tsconfig.build.json b/packages/wallet-cli/tsconfig.build.json index 02a0eea03f..9c2e2b623e 100644 --- a/packages/wallet-cli/tsconfig.build.json +++ b/packages/wallet-cli/tsconfig.build.json @@ -5,6 +5,9 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../wallet/tsconfig.build.json" } + ], "include": ["../../types", "./src"] } diff --git a/packages/wallet-cli/tsconfig.json b/packages/wallet-cli/tsconfig.json index 6b19ba8bbc..f648b01038 100644 --- a/packages/wallet-cli/tsconfig.json +++ b/packages/wallet-cli/tsconfig.json @@ -3,6 +3,9 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], + "references": [ + { "path": "../base-controller/tsconfig.json" }, + { "path": "../wallet/tsconfig.json" } + ], "include": ["../../types", "./bin", "./src"] } diff --git a/teams.json b/teams.json index 571fe2d410..f16045176a 100644 --- a/teams.json +++ b/teams.json @@ -83,5 +83,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/smart-transactions-controller": "team-transactions" } diff --git a/tsconfig.build.json b/tsconfig.build.json index 00ea5a9aec..89c3707a45 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -235,6 +235,9 @@ { "path": "./packages/signature-controller/tsconfig.build.json" }, + { + "path": "./packages/smart-transactions-controller/tsconfig.build.json" + }, { "path": "./packages/snap-account-service/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 1bdf352a2f..c67aa45d06 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -227,6 +227,9 @@ { "path": "./packages/signature-controller" }, + { + "path": "./packages/smart-transactions-controller" + }, { "path": "./packages/snap-account-service" }, diff --git a/yarn.config.cjs b/yarn.config.cjs index c73d56a0dc..62eac4130d 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -176,11 +176,16 @@ 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-cli prepends a better-sqlite3 prebuild fetch to its + // "test" script because the native addon isn't built during + // `yarn install` (Yarn runs with `enableScripts: false`). + if (workspace.ident !== '@metamask/wallet-cli') { + 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( @@ -262,7 +267,14 @@ module.exports = defineConfig({ } // All packages must specify a minimum Node.js version of 18.18. - expectWorkspaceField(workspace, 'engines.node', '^18.18 || >=20'); + // @metamask/wallet-cli depends on `better-sqlite3`, which only ships + // prebuilt binaries for Node 20+; bumping its declared minimum keeps the + // engines field honest. + if (workspace.ident === '@metamask/wallet-cli') { + expectWorkspaceField(workspace, 'engines.node', '>=20'); + } else { + expectWorkspaceField(workspace, 'engines.node', '^18.18 || >=20'); + } // All non-root public packages should be published to the NPM registry; // all non-root private packages should not. @@ -534,6 +546,7 @@ async function expectWorkspaceLicense(workspace) { '@metamask/permission-log-controller', '@metamask/eth-json-rpc-middleware', '@metamask/eth-json-rpc-provider', + '@metamask/smart-transactions-controller', ].includes(workspace.manifest.name) ) { expectWorkspaceField(workspace, 'license'); diff --git a/yarn.lock b/yarn.lock index a22201eb7e..e3cd61b228 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8338,6 +8338,43 @@ __metadata: languageName: node linkType: hard +"@metamask/smart-transactions-controller@workspace:packages/smart-transactions-controller": + version: 0.0.0-use.local + resolution: "@metamask/smart-transactions-controller@workspace:packages/smart-transactions-controller" + dependencies: + "@babel/runtime": "npm:^7.23.9" + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/keccak256": "npm:^5.8.0" + "@ethersproject/transactions": "npm:^5.7.0" + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/controller-utils": "npm:^12.2.0" + "@metamask/eth-json-rpc-provider": "npm:^6.0.1" + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/json-rpc-engine": "npm:^10.5.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/network-controller": "npm:^32.0.0" + "@metamask/polling-controller": "npm:^16.0.6" + "@metamask/profile-sync-controller": "npm:^28.2.0" + "@metamask/remote-feature-flag-controller": "npm:^4.2.2" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/transaction-controller": "npm:^68.0.0" + "@metamask/utils": "npm:^11.11.0" + "@ts-bridge/cli": "npm:^0.6.4" + bignumber.js: "npm:^9.1.2" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + lodash: "npm:^4.17.21" + nock: "npm:^13.3.1" + reselect: "npm:^5.1.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 + "@metamask/snap-account-service@npm:^0.3.1, @metamask/snap-account-service@workspace:packages/snap-account-service": version: 0.0.0-use.local resolution: "@metamask/snap-account-service@workspace:packages/snap-account-service" @@ -8764,10 +8801,16 @@ __metadata: resolution: "@metamask/wallet-cli@workspace:packages/wallet-cli" dependencies: "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/utils": "npm:^11.11.0" + "@metamask/wallet": "npm:^3.0.0" "@oclif/core": "npm:^4.10.5" "@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" + immer: "npm:^9.0.6" jest: "npm:^29.7.0" ts-jest: "npm:^29.2.5" typescript: "npm:~5.3.3" @@ -8802,7 +8845,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/wallet@workspace:packages/wallet": +"@metamask/wallet@npm:^3.0.0, @metamask/wallet@workspace:packages/wallet": version: 0.0.0-use.local resolution: "@metamask/wallet@workspace:packages/wallet" dependencies: @@ -10729,6 +10772,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" @@ -12432,6 +12484,17 @@ __metadata: languageName: node linkType: hard +"better-sqlite3@npm:^12.9.0": + version: 12.10.0 + resolution: "better-sqlite3@npm:12.10.0" + dependencies: + bindings: "npm:^1.5.0" + node-gyp: "npm:latest" + prebuild-install: "npm:^7.1.1" + checksum: 10/99e213e78f15a7f40d5cb666b56781223a6b83ffc317c93846e2b63b694592978c1e3762e81afd6ce851e0e3c24ebc9d0c42341158ea93c8a39987e5e19602f8 + languageName: node + linkType: hard + "big.js@npm:^5.2.2": version: 5.2.2 resolution: "big.js@npm:5.2.2" @@ -12465,6 +12528,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" @@ -12476,6 +12548,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" @@ -12712,6 +12795,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" @@ -13016,6 +13109,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" @@ -14148,6 +14248,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" @@ -14473,6 +14580,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, enhanced-resolve@npm:^5.22.0": version: 5.22.0 resolution: "enhanced-resolve@npm:5.22.0" @@ -15378,6 +15494,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 + "expect@npm:^29.0.0, expect@npm:^29.7.0": version: 29.7.0 resolution: "expect@npm:29.7.0" @@ -15691,6 +15814,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 + "filelist@npm:^1.0.4": version: 1.0.6 resolution: "filelist@npm:1.0.6" @@ -15910,6 +16040,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.0.0, fs-extra@npm:^10.1.0": version: 10.1.0 resolution: "fs-extra@npm:10.1.0" @@ -16079,6 +16216,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 + "github-slugger@npm:^1.5.0": version: 1.5.0 resolution: "github-slugger@npm:1.5.0" @@ -16861,7 +17005,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 @@ -19679,7 +19823,7 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.0, 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 @@ -19786,6 +19930,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" @@ -19860,6 +20011,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" @@ -19909,6 +20067,15 @@ __metadata: languageName: node linkType: hard +"node-abi@npm:^3.3.0": + version: 3.92.0 + resolution: "node-abi@npm:3.92.0" + dependencies: + semver: "npm:^7.3.5" + checksum: 10/b57a8eaa3e0f0531688b7f9c85ca0831e8b1195c9c331205f8a5ec3aa4e0a898671b85c8a0a0f4469ce550ce2cd32df1a4ccf437a7518bbff6459dc88f59d3a5 + languageName: node + linkType: hard + "node-addon-api@npm:^2.0.0": version: 2.0.2 resolution: "node-addon-api@npm:2.0.2" @@ -20195,7 +20362,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: @@ -21750,6 +21917,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" @@ -21965,6 +22154,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" @@ -22095,7 +22294,7 @@ __metadata: languageName: node linkType: hard -"rc@npm:1.2.8": +"rc@npm:1.2.8, rc@npm:^1.2.7": version: 1.2.8 resolution: "rc@npm:1.2.8" dependencies: @@ -22251,7 +22450,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:3.6.2, readable-stream@npm:^3.0.2, readable-stream@npm:^3.0.6, 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.0.6, 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: @@ -23243,6 +23442,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" @@ -23856,6 +24073,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" @@ -24198,6 +24440,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"