diff --git a/.gitattributes b/.gitattributes index 524151a2322..a79ca9ed555 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,6 +2,10 @@ yarn.lock linguist-generated=false +# `tsconfig.json` is already recognized as JSONC. This ensures our other `tsconfig` files are as +# well. They all use this naming convention. +tsconfig.**.json linguist-language=jsonc + # yarn v3 # See: https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored /.yarn/releases/** binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b1e33e13356..d845f3fe8d7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -31,8 +31,9 @@ /packages/user-operation-controller @MetaMask/confirmations ## Delegation Team -/packages/gator-permissions-controller @MetaMask/delegation -/packages/eip-7702-internal-rpc-middleware @MetaMask/delegation @MetaMask/core-platform +/packages/delegation-controller @MetaMask/delegation +/packages/gator-permissions-controller @MetaMask/delegation +/packages/eip-7702-internal-rpc-middleware @MetaMask/delegation @MetaMask/core-platform ## Earn Team /packages/earn-controller @MetaMask/earn @@ -54,9 +55,6 @@ ## Portfolio Team /packages/token-search-discovery-controller @MetaMask/portfolio -## Vault Team -/packages/delegation-controller @MetaMask/vault - ## Wallet Integrations Team /packages/chain-agnostic-permission @MetaMask/wallet-integrations /packages/eip1193-permission-middleware @MetaMask/wallet-integrations @@ -76,6 +74,7 @@ /packages/polling-controller @MetaMask/core-platform /packages/preferences-controller @MetaMask/core-platform /packages/rate-limit-controller @MetaMask/core-platform +/packages/profile-metrics-controller @MetaMask/core-platform ## Web3Auth Team /packages/seedless-onboarding-controller @MetaMask/web3auth @@ -99,6 +98,7 @@ /packages/permission-controller @MetaMask/wallet-integrations @MetaMask/core-platform /packages/permission-log-controller @MetaMask/wallet-integrations @MetaMask/core-platform /packages/remote-feature-flag-controller @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/storage-service @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform ## Package Release related /packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform @@ -117,8 +117,8 @@ /packages/assets-controllers/CHANGELOG.md @MetaMask/metamask-assets @MetaMask/core-platform /packages/chain-agnostic-permission/package.json @MetaMask/wallet-integrations @MetaMask/core-platform /packages/chain-agnostic-permission/CHANGELOG.md @MetaMask/wallet-integrations @MetaMask/core-platform -/packages/delegation-controller/package.json @MetaMask/vault @MetaMask/core-platform -/packages/delegation-controller/CHANGELOG.md @MetaMask/vault @MetaMask/core-platform +/packages/delegation-controller/package.json @MetaMask/delegation @MetaMask/core-platform +/packages/delegation-controller/CHANGELOG.md @MetaMask/delegation @MetaMask/core-platform /packages/earn-controller/package.json @MetaMask/earn @MetaMask/core-platform /packages/earn-controller/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform /packages/eip-5792-middleware/package.json @MetaMask/wallet-integrations @MetaMask/core-platform @@ -167,6 +167,8 @@ /packages/bridge-controller/CHANGELOG.md @MetaMask/swaps-engineers @MetaMask/core-platform /packages/remote-feature-flag-controller/package.json @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/remote-feature-flag-controller/CHANGELOG.md @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/storage-service/package.json @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/storage-service/CHANGELOG.md @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/bridge-status-controller/package.json @MetaMask/swaps-engineers @MetaMask/core-platform /packages/bridge-status-controller/CHANGELOG.md @MetaMask/swaps-engineers @MetaMask/core-platform /packages/app-metadata-controller/package.json @MetaMask/mobile-platform @MetaMask/core-platform diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6dde800aa09..33aff010c02 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -27,5 +27,5 @@ For example: - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate -- [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary -- [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes +- [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs) +- [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index ae32560d668..9a2727c4634 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -5,14 +5,15 @@ on: types: [opened, synchronize, labeled, unlabeled] jobs: - check_changelog: - uses: MetaMask/github-tools/.github/workflows/changelog-check.yml@fc6fe1a3fb591f6afa61f0dbbe7698bd50fab9c7 - with: - action-sha: fc6fe1a3fb591f6afa61f0dbbe7698bd50fab9c7 - base-branch: ${{ github.event.pull_request.base.ref }} - head-ref: ${{ github.head_ref }} - labels: ${{ toJSON(github.event.pull_request.labels) }} - pr-number: ${{ github.event.pull_request.number }} - repo: ${{ github.repository }} - secrets: - gh-token: ${{ secrets.GITHUB_TOKEN }} + check-changelog: + name: Check changelog + runs-on: ubuntu-latest + steps: + - name: Check changelog + uses: MetaMask/github-tools/.github/actions/check-changelog@v1 + with: + base-branch: ${{ github.event.pull_request.base.ref }} + head-ref: ${{ github.head_ref }} + labels: ${{ toJSON(github.event.pull_request.labels) }} + pr-number: ${{ github.event.pull_request.number }} + repo: ${{ github.repository }} diff --git a/README.md b/README.md index 79485d9f78e..1063ee58d1b 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/phishing-controller`](packages/phishing-controller) - [`@metamask/polling-controller`](packages/polling-controller) - [`@metamask/preferences-controller`](packages/preferences-controller) +- [`@metamask/profile-metrics-controller`](packages/profile-metrics-controller) - [`@metamask/profile-sync-controller`](packages/profile-sync-controller) - [`@metamask/rate-limit-controller`](packages/rate-limit-controller) - [`@metamask/remote-feature-flag-controller`](packages/remote-feature-flag-controller) @@ -140,6 +141,7 @@ linkStyle default opacity:0.5 phishing_controller(["@metamask/phishing-controller"]); polling_controller(["@metamask/polling-controller"]); preferences_controller(["@metamask/preferences-controller"]); + profile_metrics_controller(["@metamask/profile-metrics-controller"]); profile_sync_controller(["@metamask/profile-sync-controller"]); rate_limit_controller(["@metamask/rate-limit-controller"]); remote_feature_flag_controller(["@metamask/remote-feature-flag-controller"]); @@ -153,62 +155,65 @@ linkStyle default opacity:0.5 transaction_controller(["@metamask/transaction-controller"]); transaction_pay_controller(["@metamask/transaction-pay-controller"]); user_operation_controller(["@metamask/user-operation-controller"]); - account_tree_controller --> base_controller; - account_tree_controller --> messenger; account_tree_controller --> accounts_controller; + account_tree_controller --> base_controller; account_tree_controller --> keyring_controller; + account_tree_controller --> messenger; account_tree_controller --> multichain_account_service; account_tree_controller --> profile_sync_controller; accounts_controller --> base_controller; - accounts_controller --> messenger; - accounts_controller --> controller_utils; accounts_controller --> keyring_controller; + accounts_controller --> messenger; accounts_controller --> network_controller; + accounts_controller --> controller_utils; address_book_controller --> base_controller; address_book_controller --> controller_utils; address_book_controller --> messenger; + analytics_controller --> base_controller; + analytics_controller --> messenger; announcement_controller --> base_controller; announcement_controller --> messenger; app_metadata_controller --> base_controller; app_metadata_controller --> messenger; approval_controller --> base_controller; approval_controller --> messenger; - assets_controllers --> base_controller; - assets_controllers --> controller_utils; - assets_controllers --> messenger; - assets_controllers --> polling_controller; assets_controllers --> account_tree_controller; assets_controllers --> accounts_controller; assets_controllers --> approval_controller; + assets_controllers --> base_controller; + assets_controllers --> controller_utils; assets_controllers --> core_backend; assets_controllers --> keyring_controller; + assets_controllers --> messenger; assets_controllers --> multichain_account_service; assets_controllers --> network_controller; assets_controllers --> permission_controller; assets_controllers --> phishing_controller; + assets_controllers --> polling_controller; assets_controllers --> preferences_controller; + assets_controllers --> profile_sync_controller; assets_controllers --> transaction_controller; base_controller --> messenger; base_controller --> json_rpc_engine; + bridge_controller --> accounts_controller; + bridge_controller --> assets_controllers; bridge_controller --> base_controller; bridge_controller --> controller_utils; bridge_controller --> gas_fee_controller; bridge_controller --> messenger; bridge_controller --> multichain_network_controller; - bridge_controller --> polling_controller; - bridge_controller --> accounts_controller; - bridge_controller --> assets_controllers; - bridge_controller --> eth_json_rpc_provider; bridge_controller --> network_controller; + bridge_controller --> polling_controller; bridge_controller --> remote_feature_flag_controller; bridge_controller --> transaction_controller; - bridge_status_controller --> base_controller; - bridge_status_controller --> controller_utils; - bridge_status_controller --> polling_controller; + bridge_controller --> eth_json_rpc_provider; bridge_status_controller --> accounts_controller; + bridge_status_controller --> base_controller; bridge_status_controller --> bridge_controller; + bridge_status_controller --> controller_utils; bridge_status_controller --> gas_fee_controller; bridge_status_controller --> network_controller; + bridge_status_controller --> polling_controller; bridge_status_controller --> transaction_controller; chain_agnostic_permission --> controller_utils; chain_agnostic_permission --> network_controller; @@ -216,22 +221,24 @@ linkStyle default opacity:0.5 claims_controller --> base_controller; claims_controller --> controller_utils; claims_controller --> messenger; + claims_controller --> keyring_controller; + claims_controller --> profile_sync_controller; composable_controller --> base_controller; composable_controller --> messenger; composable_controller --> json_rpc_engine; + core_backend --> accounts_controller; core_backend --> controller_utils; + core_backend --> keyring_controller; core_backend --> messenger; core_backend --> profile_sync_controller; - core_backend --> accounts_controller; - core_backend --> keyring_controller; - delegation_controller --> base_controller; - delegation_controller --> messenger; delegation_controller --> accounts_controller; + delegation_controller --> base_controller; delegation_controller --> keyring_controller; + delegation_controller --> messenger; + earn_controller --> account_tree_controller; earn_controller --> base_controller; earn_controller --> controller_utils; earn_controller --> messenger; - earn_controller --> account_tree_controller; earn_controller --> network_controller; earn_controller --> transaction_controller; eip_5792_middleware --> messenger; @@ -253,15 +260,17 @@ linkStyle default opacity:0.5 eth_json_rpc_middleware --> eth_block_tracker; eth_json_rpc_middleware --> eth_json_rpc_provider; eth_json_rpc_middleware --> json_rpc_engine; + eth_json_rpc_middleware --> message_manager; eth_json_rpc_middleware --> error_reporting_service; eth_json_rpc_middleware --> network_controller; eth_json_rpc_provider --> json_rpc_engine; gas_fee_controller --> base_controller; gas_fee_controller --> controller_utils; - gas_fee_controller --> polling_controller; gas_fee_controller --> network_controller; + gas_fee_controller --> polling_controller; gator_permissions_controller --> base_controller; gator_permissions_controller --> messenger; + gator_permissions_controller --> transaction_controller; json_rpc_middleware_stream --> json_rpc_engine; keyring_controller --> base_controller; keyring_controller --> messenger; @@ -271,38 +280,39 @@ linkStyle default opacity:0.5 message_manager --> base_controller; message_manager --> controller_utils; message_manager --> messenger; - multichain_account_service --> base_controller; - multichain_account_service --> messenger; multichain_account_service --> accounts_controller; + multichain_account_service --> base_controller; + multichain_account_service --> error_reporting_service; multichain_account_service --> keyring_controller; + multichain_account_service --> messenger; multichain_api_middleware --> chain_agnostic_permission; multichain_api_middleware --> controller_utils; multichain_api_middleware --> json_rpc_engine; multichain_api_middleware --> network_controller; multichain_api_middleware --> permission_controller; multichain_api_middleware --> multichain_transactions_controller; + multichain_network_controller --> accounts_controller; multichain_network_controller --> base_controller; multichain_network_controller --> controller_utils; multichain_network_controller --> messenger; - multichain_network_controller --> accounts_controller; - multichain_network_controller --> keyring_controller; multichain_network_controller --> network_controller; + multichain_network_controller --> keyring_controller; + multichain_transactions_controller --> accounts_controller; multichain_transactions_controller --> base_controller; multichain_transactions_controller --> messenger; multichain_transactions_controller --> polling_controller; - multichain_transactions_controller --> accounts_controller; multichain_transactions_controller --> keyring_controller; name_controller --> base_controller; name_controller --> controller_utils; name_controller --> messenger; network_controller --> base_controller; network_controller --> controller_utils; + network_controller --> error_reporting_service; network_controller --> eth_block_tracker; network_controller --> eth_json_rpc_middleware; network_controller --> eth_json_rpc_provider; network_controller --> json_rpc_engine; network_controller --> messenger; - network_controller --> error_reporting_service; network_enablement_controller --> base_controller; network_enablement_controller --> controller_utils; network_enablement_controller --> messenger; @@ -311,14 +321,14 @@ linkStyle default opacity:0.5 network_enablement_controller --> transaction_controller; notification_services_controller --> base_controller; notification_services_controller --> controller_utils; - notification_services_controller --> messenger; notification_services_controller --> keyring_controller; + notification_services_controller --> messenger; notification_services_controller --> profile_sync_controller; + permission_controller --> approval_controller; permission_controller --> base_controller; permission_controller --> controller_utils; permission_controller --> json_rpc_engine; permission_controller --> messenger; - permission_controller --> approval_controller; permission_log_controller --> base_controller; permission_log_controller --> json_rpc_engine; permission_log_controller --> messenger; @@ -331,12 +341,16 @@ linkStyle default opacity:0.5 polling_controller --> network_controller; preferences_controller --> base_controller; preferences_controller --> controller_utils; - preferences_controller --> messenger; preferences_controller --> keyring_controller; - profile_sync_controller --> base_controller; - profile_sync_controller --> messenger; + preferences_controller --> messenger; + profile_metrics_controller --> base_controller; + profile_metrics_controller --> controller_utils; + profile_metrics_controller --> messenger; + profile_metrics_controller --> profile_sync_controller; profile_sync_controller --> address_book_controller; + profile_sync_controller --> base_controller; profile_sync_controller --> keyring_controller; + profile_sync_controller --> messenger; rate_limit_controller --> base_controller; rate_limit_controller --> messenger; remote_feature_flag_controller --> base_controller; @@ -344,11 +358,11 @@ linkStyle default opacity:0.5 remote_feature_flag_controller --> messenger; sample_controllers --> base_controller; sample_controllers --> messenger; - sample_controllers --> controller_utils; sample_controllers --> network_controller; + sample_controllers --> controller_utils; seedless_onboarding_controller --> base_controller; - seedless_onboarding_controller --> messenger; seedless_onboarding_controller --> keyring_controller; + seedless_onboarding_controller --> messenger; selected_network_controller --> base_controller; selected_network_controller --> json_rpc_engine; selected_network_controller --> messenger; @@ -359,53 +373,53 @@ linkStyle default opacity:0.5 shield_controller --> messenger; shield_controller --> signature_controller; shield_controller --> transaction_controller; - signature_controller --> base_controller; - signature_controller --> controller_utils; - signature_controller --> messenger; signature_controller --> accounts_controller; signature_controller --> approval_controller; + signature_controller --> base_controller; + signature_controller --> controller_utils; signature_controller --> gator_permissions_controller; signature_controller --> keyring_controller; signature_controller --> logging_controller; + signature_controller --> messenger; signature_controller --> network_controller; subscription_controller --> base_controller; subscription_controller --> controller_utils; subscription_controller --> messenger; subscription_controller --> polling_controller; - subscription_controller --> transaction_controller; subscription_controller --> profile_sync_controller; + subscription_controller --> transaction_controller; token_search_discovery_controller --> base_controller; token_search_discovery_controller --> messenger; - transaction_controller --> base_controller; - transaction_controller --> controller_utils; - transaction_controller --> messenger; transaction_controller --> accounts_controller; transaction_controller --> approval_controller; - transaction_controller --> eth_block_tracker; - transaction_controller --> eth_json_rpc_provider; + transaction_controller --> base_controller; + transaction_controller --> controller_utils; transaction_controller --> gas_fee_controller; + transaction_controller --> messenger; transaction_controller --> network_controller; transaction_controller --> remote_feature_flag_controller; - transaction_pay_controller --> base_controller; - transaction_pay_controller --> controller_utils; - transaction_pay_controller --> messenger; + transaction_controller --> eth_block_tracker; + transaction_controller --> eth_json_rpc_provider; transaction_pay_controller --> assets_controllers; + transaction_pay_controller --> base_controller; transaction_pay_controller --> bridge_controller; transaction_pay_controller --> bridge_status_controller; + transaction_pay_controller --> controller_utils; transaction_pay_controller --> gas_fee_controller; + transaction_pay_controller --> messenger; transaction_pay_controller --> network_controller; transaction_pay_controller --> remote_feature_flag_controller; transaction_pay_controller --> transaction_controller; + user_operation_controller --> approval_controller; user_operation_controller --> base_controller; user_operation_controller --> controller_utils; - user_operation_controller --> messenger; - user_operation_controller --> polling_controller; - user_operation_controller --> approval_controller; - user_operation_controller --> eth_block_tracker; user_operation_controller --> gas_fee_controller; user_operation_controller --> keyring_controller; + user_operation_controller --> messenger; user_operation_controller --> network_controller; + user_operation_controller --> polling_controller; user_operation_controller --> transaction_controller; + user_operation_controller --> eth_block_tracker; ``` diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md new file mode 100644 index 00000000000..ddc4971e4b0 --- /dev/null +++ b/docs/breaking-changes.md @@ -0,0 +1,84 @@ +# Preparing & Releasing Breaking Changes + +When developing packages, it is always important to be intentional about the impact that changes have on projects which use those packages. However, special consideration must be given to breaking changes. + +This guide provides best practices for documenting, preparing, releasing, and adapting to breaking changes within `core` and in other projects. + +## What is a breaking change? + +A change to a package is "breaking" if upgrading a project to a version containing the change would require modifications to source code or configuration in order to avoid user- or developer-facing problems (an inability to use or build the project, a loss of functionality, etc.). + +There are many kinds of breaking changes. Here are some examples: + +- Removals + - Removing a method from a class + - Removing an export from a package (including a type export) +- Functional changes that require code changes or change expectations + - Changing the number of required arguments for a function or method + - Throwing a new error in a function or method + - Changing a function or method so that it no longer fires an event +- Breaking changes to types + - Adding external actions or events to a messenger type + - Narrowing the type of an argument in a function or method + - Changing the number of required properties in an object type + - Narrowing the type of a property in an object type + - Changing the number of type parameters for a type + - Making any other change listed [here](https://www.semver-ts.org/formal-spec/2-breaking-changes.html) +- Bumping the minimum supported Node.js version of a package +- Upgrading a dependency referenced in published code to a version that causes any of the above + +## Introducing breaking changes safely + +Before merging a PR that introduces breaking changes, it is important to ensure that they are accounted for among projects. + +### 1. Document breaking changes + +To inform other maintainers now and in the future, make sure that breaking changes are documented in the changelog: + +1. Be explicit in your changelog entries about which classes, functions, types, etc. are affected. +2. Prefix entries with `**BREAKING:**`. +3. If relevant, provide details on how consuming code can adapt to the changes safely. +4. Move entries for breaking changes above all other kinds of changes within the same section. + +For example: + +```markdown +### Changed + +- **BREAKING:** Add a required `source` argument to `getTransactions` ([#1111](https://github.com/MetaMask/core/pull/1111)) +- **BREAKING:** Rename `Prices` to `PricesResponse` ([#2222](https://github.com/MetaMask/core/pull/2222)) +- **BREAKING:** `destroy` is now async ([#3333](https://github.com/MetaMask/core/pull/3333)) +- Widen the type of `getNetworkClientId` to return `string` ([#4444](https://github.com/MetaMask/core/pull/4444)) + +### Removed + +- **BREAKING:** Remove `fetchGasPrices` from `GasPricesController` ([#5555](https://github.com/MetaMask/core/pull/5555)) + - Please use `GasPriceService` instead. +``` + +### 2. Audit dependents + +When you release your changes, which codebases will be affected? + +Using the changelog as a guide, locate all of the places across MetaMask that use the affected classes, functions, types, etc.: + +- To find dependents of your package within `core`, look in the package's `package.json`, or simply search across the repo. +- To find dependents of your package across MetaMask, do a search on GitHub for import statements or, better, usages of the affected symbols. + +### 3. Prepare upgrade PRs for dependents + +Finally, how will dependent projects need to adapt to your breaking changes? + +For dependent packages located in `core`, you may get type errors immediately that you will have to fix in the same PR that introduces the breaking changes. Otherwise, create new PRs to migrate existing code. + +For other projects that live outside of `core`, you can use the following process to verify the effects: + +1. Create a [preview build](./contributing.md#testing-changes-to-packages-with-preview-builds) for your package. +2. Open draft PRs in the dependent projects. +3. In each draft PR, upgrade your package to the preview build. +4. Test the project, particularly the functionality that makes use of your package. +5. If you see compile-time or runtime errors, make changes to the project as necessary. +6. If you discover new breaking changes in your package that you haven't yet listed in the changelog, go back and [document them](#1-document-breaking-changes). +7. Once you've done this for all projects, check off the "I've followed the process for releasing breaking changes" item in the checklist at the bottom of your PR. + +This process serves as a check to help you understand the full impact of your changes. It will also save you time after you make a new release, because you can reuse the draft PRs later to complete upgrades. diff --git a/docs/contributing.md b/docs/contributing.md index d40b2b3d462..b7c94829e70 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -11,6 +11,7 @@ - [Creating pull requests](#creating-pull-requests) - [Testing changes to packages in another project](#testing-changes-to-packages-in-another-project) - [Releasing changes](#releasing-changes) + - [Preparing and releasing breaking changes](./breaking-changes.md) - [Performing operations across the monorepo](#performing-operations-across-the-monorepo) - [Adding new packages to the monorepo](#adding-new-packages-to-the-monorepo) @@ -201,6 +202,7 @@ Have changes that you need to release? There are a few things to understand: - The responsibility of maintenance is not the only thing shared among multiple teams at MetaMask; releases are as well. That means **if you work on a team that has codeownership over a package, you are free to create a new release without needing the Wallet Framework team to do so.** - Unlike clients, releases are not issued on a schedule; **anyone may create a release at any time**. Because of this, you may wish to review the Pull Requests tab on GitHub and ensure that no one else has a release candidate already in progress. If not, then you are free to start the process. - The release process is a work in progress. Further improvements to simplify the process are planned, but in the meantime, if you encounter any issues, please reach out to the Wallet Framework team. +- Breaking changes take special consideration. [Read the guide](./breaking-changes.md) on how to prepare and handle them effectively. Now for the process itself, you have two options: using our interactive UI (recommended for most users) or manual specification. diff --git a/docs/reviewing-release-prs.md b/docs/reviewing-release-prs.md index 685e75ff7b8..2e7fdb8155e 100644 --- a/docs/reviewing-release-prs.md +++ b/docs/reviewing-release-prs.md @@ -90,24 +90,9 @@ With that in mind, there are three ways changes can be categorized: ### Breaking changes -A change is "breaking" if it is included in a version of a package, which, after a consumer upgrades to it and makes no further changes, causes one of the following: - -- An error at runtime -- An error at compile time (e.g., a TypeScript type error) -- An error at install time (e.g., the consumer's package manager reports a Node version incompatibility) -- A surprising difference in behavior - -Given this, there are many ways a change could be regarded as breaking: - -- Changing a function or method to throw an error -- Adding a required argument to a function or method -- Adding a required property to a TypeScript type -- Narrowing the type of a property in a TypeScript type -- Narrowing the type of an argument in a function -- Adding or removing a parameter to a TypeScript type -- Upgrading a runtime dependency to a version which causes any of the above -- Removing an export from the package -- Bumping the minimum supported Node.js version +A change is "breaking" if upgrading a project to a version containing the change would require modifications to source code or configuration in order to avoid user- or developer-facing problems (an inability to use or build the project, a loss of functionality, etc.). + +There are many kinds of breaking changes. You can find a list of examples [here](./breaking-changes.md#what-is-a-breaking-change). ### Additions diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 2e91fe99569..ffe8e50d8bd 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1,9 +1,210 @@ { + "packages/account-tree-controller/src/AccountTreeController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/naming-convention": { + "count": 4 + }, + "id-length": { + "count": 2 + } + }, + "packages/account-tree-controller/src/AccountTreeController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 10 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "no-negated-condition": { + "count": 3 + } + }, + "packages/account-tree-controller/src/backup-and-sync/analytics/segment.ts": { + "@typescript-eslint/naming-convention": { + "count": 3 + }, + "no-negated-condition": { + "count": 1 + } + }, + "packages/account-tree-controller/src/backup-and-sync/service/index.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/unbound-method": { + "count": 29 + }, + "n/no-sync": { + "count": 22 + } + }, + "packages/account-tree-controller/src/backup-and-sync/service/index.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/account-tree-controller/src/backup-and-sync/syncing/group.test.ts": { + "@typescript-eslint/unbound-method": { + "count": 12 + } + }, + "packages/account-tree-controller/src/backup-and-sync/syncing/group.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "id-length": { + "count": 1 + } + }, + "packages/account-tree-controller/src/backup-and-sync/syncing/legacy.test.ts": { + "@typescript-eslint/unbound-method": { + "count": 7 + } + }, + "packages/account-tree-controller/src/backup-and-sync/syncing/legacy.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/account-tree-controller/src/backup-and-sync/syncing/metadata.ts": { + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/account-tree-controller/src/backup-and-sync/syncing/wallet.test.ts": { + "@typescript-eslint/unbound-method": { + "count": 1 + } + }, + "packages/account-tree-controller/src/backup-and-sync/types.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/account-tree-controller/src/backup-and-sync/user-storage/network-operations.test.ts": { + "@typescript-eslint/unbound-method": { + "count": 7 + } + }, + "packages/account-tree-controller/src/backup-and-sync/user-storage/network-utils.ts": { + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/account-tree-controller/src/backup-and-sync/user-storage/validation.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/account-tree-controller/src/backup-and-sync/utils/controller.test.ts": { + "@typescript-eslint/unbound-method": { + "count": 1 + }, + "id-length": { + "count": 1 + } + }, + "packages/account-tree-controller/src/rules/entropy.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/account-tree-controller/src/rules/keyring.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/account-tree-controller/src/rules/snap.ts": { + "@typescript-eslint/prefer-optional-chain": { + "count": 1 + } + }, + "packages/account-tree-controller/src/type-utils.ts": { + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/account-tree-controller/tests/mockMessenger.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, "packages/accounts-controller/src/AccountsController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 15 + }, + "@typescript-eslint/unbound-method": { + "count": 9 + }, "import-x/namespace": { "count": 1 } }, + "packages/accounts-controller/src/AccountsController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 10 + } + }, + "packages/accounts-controller/src/tests/mocks.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/accounts-controller/src/utils.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/address-book-controller/src/AddressBookController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/address-book-controller/src/AddressBookController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/naming-convention": { + "count": 3 + }, + "no-param-reassign": { + "count": 2 + } + }, + "packages/analytics-controller/src/AnalyticsController.test.ts": { + "@typescript-eslint/unbound-method": { + "count": 10 + } + }, + "packages/announcement-controller/src/AnnouncementController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/approval-controller/src/ApprovalController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 22 + }, + "id-denylist": { + "count": 3 + } + }, + "packages/approval-controller/src/ApprovalController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 6 + }, + "no-restricted-syntax": { + "count": 1 + } + }, "packages/assets-controllers/jest.environment.js": { "n/prefer-global/text-decoder": { "count": 1 @@ -15,279 +216,3835 @@ "count": 2 } }, + "packages/assets-controllers/src/AccountTrackerController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, "packages/assets-controllers/src/AccountTrackerController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 14 + }, + "@typescript-eslint/no-misused-promises": { + "count": 4 + }, + "id-denylist": { + "count": 6 + }, + "id-length": { + "count": 1 + }, + "no-restricted-syntax": { + "count": 1 + } + }, + "packages/assets-controllers/src/AssetsContractController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + }, + "id-length": { + "count": 1 + } + }, + "packages/assets-controllers/src/AssetsContractController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 6 + }, + "id-length": { + "count": 1 + } + }, + "packages/assets-controllers/src/CurrencyRateController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 10 + } + }, + "packages/assets-controllers/src/CurrencyRateController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "no-negated-condition": { + "count": 1 + }, + "no-restricted-syntax": { + "count": 3 + } + }, + "packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/no-misused-promises": { + "count": 2 + } + }, + "packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.ts": { + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "id-length": { + "count": 2 + } + }, + "packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + }, + "@typescript-eslint/no-misused-promises": { + "count": 3 + } + }, + "packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "@typescript-eslint/no-misused-promises": { + "count": 2 + } + }, + "packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/unbound-method": { + "count": 1 + }, + "jest/unbound-method": { + "count": 1 + } + }, + "packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts": { + "@typescript-eslint/no-misused-promises": { + "count": 2 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/assets-controllers/src/NftController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "id-denylist": { + "count": 1 + }, + "import-x/namespace": { + "count": 9 + } + }, + "packages/assets-controllers/src/NftController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 21 + }, + "@typescript-eslint/naming-convention": { + "count": 5 + }, "@typescript-eslint/no-misused-promises": { + "count": 2 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 9 + }, + "@typescript-eslint/prefer-optional-chain": { + "count": 1 + }, + "camelcase": { + "count": 15 + }, + "id-length": { + "count": 1 + }, + "no-negated-condition": { + "count": 1 + }, + "no-param-reassign": { + "count": 2 + } + }, + "packages/assets-controllers/src/NftDetectionController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "import-x/namespace": { + "count": 6 + } + }, + "packages/assets-controllers/src/NftDetectionController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/naming-convention": { + "count": 26 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "id-length": { + "count": 1 + } + }, + "packages/assets-controllers/src/RatesController/RatesController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/assets-controllers/src/RatesController/RatesController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/assets-controllers/src/Standards/ERC20Standard.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "jest/no-commented-out-tests": { + "count": 1 + } + }, + "packages/assets-controllers/src/Standards/ERC20Standard.ts": { + "id-denylist": { + "count": 8 + }, + "no-restricted-syntax": { + "count": 1 + } + }, + "packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "import-x/no-named-as-default-member": { + "count": 1 + } + }, + "packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.ts": { + "no-restricted-syntax": { + "count": 2 + } + }, + "packages/assets-controllers/src/Standards/NftStandards/ERC721/ERC721Standard.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/assets-controllers/src/Standards/NftStandards/ERC721/ERC721Standard.ts": { + "id-denylist": { + "count": 4 + }, + "no-restricted-syntax": { + "count": 2 + } + }, + "packages/assets-controllers/src/TokenBalancesController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/unbound-method": { + "count": 1 + }, + "camelcase": { + "count": 1 + }, + "jest/unbound-method": { + "count": 1 + }, + "require-atomic-updates": { + "count": 1 + } + }, + "packages/assets-controllers/src/TokenBalancesController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 19 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "@typescript-eslint/no-misused-promises": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 2 + }, + "id-denylist": { + "count": 6 + }, + "id-length": { + "count": 7 + } + }, + "packages/assets-controllers/src/TokenDetectionController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + }, + "id-length": { + "count": 1 + } + }, + "packages/assets-controllers/src/TokenDetectionController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 12 + }, + "@typescript-eslint/naming-convention": { + "count": 4 + }, + "@typescript-eslint/no-misused-promises": { + "count": 5 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/assets-controllers/src/TokenListController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "id-denylist": { + "count": 2 + }, + "import-x/namespace": { + "count": 7 + } + }, + "packages/assets-controllers/src/TokenListController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 6 + }, + "no-restricted-syntax": { + "count": 7 + } + }, + "packages/assets-controllers/src/TokenRatesController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/unbound-method": { + "count": 21 + } + }, + "packages/assets-controllers/src/TokenRatesController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + } + }, + "packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "no-negated-condition": { + "count": 2 + } + }, + "packages/assets-controllers/src/TokensController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 6 + }, + "import-x/namespace": { + "count": 1 + } + }, + "packages/assets-controllers/src/TokensController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 14 + }, + "@typescript-eslint/no-unused-vars": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 4 + }, + "@typescript-eslint/prefer-optional-chain": { + "count": 4 + }, + "id-length": { + "count": 1 + }, + "no-negated-condition": { + "count": 3 + }, + "no-param-reassign": { + "count": 1 + }, + "require-atomic-updates": { + "count": 1 + } + }, + "packages/assets-controllers/src/__fixtures__/account-api-v4-mocks.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/naming-convention": { + "count": 2 + }, + "camelcase": { + "count": 2 + } + }, + "packages/assets-controllers/src/assetsUtil.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "id-length": { + "count": 1 + } + }, + "packages/assets-controllers/src/assetsUtil.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 6 + }, + "@typescript-eslint/naming-convention": { + "count": 22 + }, + "no-negated-condition": { + "count": 2 + } + }, + "packages/assets-controllers/src/balances.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/assets-controllers/src/balances.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 13 + }, + "id-length": { + "count": 2 + }, + "no-negated-condition": { + "count": 2 + } + }, + "packages/assets-controllers/src/crypto-compare-service/crypto-compare.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "@typescript-eslint/naming-convention": { + "count": 2 + }, + "id-length": { + "count": 1 + }, + "no-param-reassign": { + "count": 4 + } + }, + "packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 11 + }, + "id-length": { + "count": 37 + }, + "require-atomic-updates": { + "count": 1 + } + }, + "packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "id-length": { + "count": 1 + } + }, + "packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + } + }, + "packages/assets-controllers/src/multicall.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/prefer-promise-reject-errors": { + "count": 2 + }, + "@typescript-eslint/unbound-method": { + "count": 2 + } + }, + "packages/assets-controllers/src/multicall.ts": { + "id-length": { + "count": 2 + } + }, + "packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.test.ts": { + "id-length": { + "count": 17 + } + }, + "packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 2 + }, + "id-length": { + "count": 1 + } + }, + "packages/assets-controllers/src/selectors/stringify-balance.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/assets-controllers/src/selectors/token-selectors.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/assets-controllers/src/selectors/token-selectors.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 25 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "no-negated-condition": { + "count": 1 + } + }, + "packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + } + }, + "packages/assets-controllers/src/token-prices-service/codefi-v2.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/assets-controllers/src/token-service.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 2 + }, + "no-restricted-globals": { + "count": 1 + } + }, + "packages/assets-controllers/src/utils/formatters.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 9 + } + }, + "packages/assets-controllers/src/utils/timeout-with-retry.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/assets-controllers/src/utils/timeout-with-retry.ts": { + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "id-denylist": { + "count": 3 + } + }, + "packages/base-controller/src/BaseController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 15 + }, + "import-x/namespace": { + "count": 13 + }, + "no-new": { + "count": 2 + } + }, + "packages/base-controller/src/BaseController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "@typescript-eslint/naming-convention": { + "count": 3 + }, + "id-denylist": { + "count": 1 + } + }, + "packages/bridge-controller/src/bridge-controller.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "@typescript-eslint/unbound-method": { + "count": 1 + } + }, + "packages/bridge-controller/src/bridge-controller.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 18 + }, + "@typescript-eslint/naming-convention": { + "count": 4 + } + }, + "packages/bridge-controller/src/selectors.test.ts": { + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/bridge-controller/src/selectors.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 25 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "no-negated-condition": { + "count": 1 + } + }, + "packages/bridge-controller/src/types.ts": { + "@typescript-eslint/naming-convention": { + "count": 12 + }, + "@typescript-eslint/prefer-enum-initializers": { + "count": 3 + } + }, + "packages/bridge-controller/src/utils/assets.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/bridge-controller/src/utils/balance.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/bridge-controller/src/utils/bridge.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 9 + }, + "id-denylist": { + "count": 1 + } + }, + "packages/bridge-controller/src/utils/caip-formatters.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/bridge-controller/src/utils/feature-flags.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/bridge-controller/src/utils/fetch-server-events.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "id-denylist": { + "count": 1 + } + }, + "packages/bridge-controller/src/utils/fetch.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 8 + }, + "id-length": { + "count": 1 + } + }, + "packages/bridge-controller/src/utils/metrics/constants.ts": { + "@typescript-eslint/naming-convention": { + "count": 2 + } + }, + "packages/bridge-controller/src/utils/metrics/properties.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + } + }, + "packages/bridge-controller/src/utils/metrics/types.ts": { + "@typescript-eslint/naming-convention": { + "count": 81 + } + }, + "packages/bridge-controller/src/utils/quote-fees.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 2 + }, + "id-length": { + "count": 2 + } + }, + "packages/bridge-controller/src/utils/quote.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 15 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 4 + } + }, + "packages/bridge-controller/src/utils/slippage.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/bridge-controller/src/utils/snaps.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/bridge-controller/src/utils/swaps.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/bridge-controller/src/utils/trade-utils.ts": { + "no-restricted-globals": { + "count": 1 + } + }, + "packages/bridge-controller/src/utils/validators.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "id-length": { + "count": 4 + } + }, + "packages/bridge-controller/tests/mock-sse.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 10 + }, + "id-length": { + "count": 2 + } + }, + "packages/bridge-status-controller/src/bridge-status-controller.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 15 + }, + "@typescript-eslint/unbound-method": { + "count": 1 + }, + "no-new": { + "count": 1 + } + }, + "packages/bridge-status-controller/src/bridge-status-controller.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 20 + }, + "@typescript-eslint/naming-convention": { + "count": 5 + }, + "camelcase": { + "count": 8 + }, + "id-length": { + "count": 1 + } + }, + "packages/bridge-status-controller/src/types.ts": { + "@typescript-eslint/naming-convention": { + "count": 7 + } + }, + "packages/bridge-status-controller/src/utils/bridge-status.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 5 + } + }, + "packages/bridge-status-controller/src/utils/gas.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + } + }, + "packages/bridge-status-controller/src/utils/metrics.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "@typescript-eslint/naming-convention": { + "count": 4 + }, + "camelcase": { + "count": 8 + } + }, + "packages/bridge-status-controller/src/utils/snaps.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/bridge-status-controller/src/utils/swap-received-amount.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/bridge-status-controller/src/utils/transaction.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/bridge-status-controller/src/utils/transaction.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/build-utils/src/transforms/remove-fenced-code.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + } + }, + "packages/build-utils/src/transforms/remove-fenced-code.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/chain-agnostic-permission/src/caip25Permission.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 11 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 2 + } + }, + "packages/chain-agnostic-permission/src/operators/caip-permission-operator-accounts.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + } + }, + "packages/chain-agnostic-permission/src/operators/caip-permission-operator-permittedChains.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/chain-agnostic-permission/src/operators/caip-permission-operator-session-scopes.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/chain-agnostic-permission/src/scope/assert.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/chain-agnostic-permission/src/scope/authorization.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/chain-agnostic-permission/src/scope/errors.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + } + }, + "packages/chain-agnostic-permission/src/scope/filter.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/chain-agnostic-permission/src/scope/supported.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + } + }, + "packages/chain-agnostic-permission/src/scope/transform.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "no-negated-condition": { + "count": 1 + } + }, + "packages/chain-agnostic-permission/src/scope/validation.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 2 + } + }, + "packages/claims-controller/src/ClaimsController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/claims-controller/src/ClaimsService.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/claims-controller/src/constants.ts": { + "@typescript-eslint/naming-convention": { + "count": 2 + } + }, + "packages/composable-controller/src/ComposableController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/naming-convention": { + "count": 16 + }, + "import-x/namespace": { + "count": 3 + }, + "no-new": { + "count": 2 + } + }, + "packages/controller-utils/jest.environment.js": { + "n/prefer-global/text-decoder": { + "count": 1 + }, + "n/prefer-global/text-encoder": { + "count": 1 + }, + "no-shadow": { + "count": 2 + } + }, + "packages/controller-utils/src/create-service-policy.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 63 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/controller-utils/src/create-service-policy.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + } + }, + "packages/controller-utils/src/siwe.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/controller-utils/src/siwe.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/no-unused-vars": { + "count": 1 + }, + "id-denylist": { + "count": 3 + }, + "id-length": { + "count": 2 + }, + "no-restricted-globals": { + "count": 1 + } + }, + "packages/controller-utils/src/types.ts": { + "@typescript-eslint/naming-convention": { + "count": 4 + } + }, + "packages/controller-utils/src/util.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "id-denylist": { + "count": 6 + }, + "import-x/no-named-as-default": { + "count": 1 + }, + "promise/param-names": { + "count": 2 + } + }, + "packages/controller-utils/src/util.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 13 + }, + "@typescript-eslint/naming-convention": { + "count": 5 + }, + "@typescript-eslint/no-base-to-string": { + "count": 1 + }, + "@typescript-eslint/no-unused-vars": { + "count": 3 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "@typescript-eslint/prefer-promise-reject-errors": { + "count": 1 + }, + "id-denylist": { + "count": 4 + }, + "id-length": { + "count": 3 + }, + "no-restricted-globals": { + "count": 1 + }, + "promise/param-names": { + "count": 3 + } + }, + "packages/core-backend/src/AccountActivityService.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + } + }, + "packages/core-backend/src/BackendWebSocketService.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 10 + }, + "@typescript-eslint/naming-convention": { + "count": 4 + }, + "no-restricted-globals": { + "count": 1 + }, + "no-restricted-syntax": { + "count": 4 + } + }, + "packages/core-backend/src/BackendWebSocketService.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/naming-convention": { + "count": 2 + }, + "camelcase": { + "count": 3 + }, + "no-restricted-globals": { + "count": 1 + } + }, + "packages/delegation-controller/src/DelegationController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/delegation-controller/src/DelegationController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + }, + "id-length": { + "count": 2 + } + }, + "packages/delegation-controller/src/types.ts": { + "@typescript-eslint/naming-convention": { + "count": 3 + } + }, + "packages/delegation-controller/src/utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/earn-controller/src/EarnController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/unbound-method": { + "count": 59 + }, + "jest/unbound-method": { + "count": 36 + } + }, + "packages/earn-controller/src/EarnController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 9 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "id-length": { + "count": 2 + }, + "no-negated-condition": { + "count": 3 + } + }, + "packages/earn-controller/src/selectors.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 6 + } + }, + "packages/eip-5792-middleware/src/constants.ts": { + "@typescript-eslint/naming-convention": { + "count": 2 + } + }, + "packages/eip-5792-middleware/src/hooks/getCallsStatus.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/eip-5792-middleware/src/hooks/getCapabilities.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/eip-5792-middleware/src/hooks/processSendCalls.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 10 + }, + "@typescript-eslint/no-misused-promises": { + "count": 1 + } + }, + "packages/eip-5792-middleware/src/methods/wallet_getCallsStatus.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/eip-5792-middleware/src/methods/wallet_getCapabilities.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/eip-5792-middleware/src/methods/wallet_sendCalls.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/eip-5792-middleware/src/utils.test.ts": { + "@typescript-eslint/unbound-method": { + "count": 1 + } + }, + "packages/eip-5792-middleware/src/utils.ts": { + "id-length": { + "count": 1 + } + }, + "packages/eip-7702-internal-rpc-middleware/src/utils.ts": { + "id-length": { + "count": 1 + } + }, + "packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.ts": { + "no-negated-condition": { + "count": 1 + } + }, + "packages/eip-7702-internal-rpc-middleware/src/wallet_upgradeAccount.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/eip-7702-internal-rpc-middleware/src/wallet_upgradeAccount.ts": { + "no-negated-condition": { + "count": 1 + } + }, + "packages/eip1193-permission-middleware/src/wallet-getPermissions.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/eip1193-permission-middleware/src/wallet-getPermissions.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/eip1193-permission-middleware/src/wallet-requestPermissions.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/eip1193-permission-middleware/src/wallet-revokePermissions.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/ens-controller/src/EnsController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 11 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "no-param-reassign": { + "count": 1 + } + }, + "packages/ens-controller/src/EnsController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 6 + } + }, + "packages/error-reporting-service/src/error-reporting-service.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "no-new": { + "count": 1 + } + }, + "packages/eth-block-tracker/src/PollingBlockTracker.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 12 + }, + "@typescript-eslint/unbound-method": { + "count": 4 + } + }, + "packages/eth-block-tracker/src/PollingBlockTracker.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 14 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 6 + }, + "@typescript-eslint/unbound-method": { + "count": 5 + }, + "no-restricted-syntax": { + "count": 28 + } + }, + "packages/eth-block-tracker/tests/buildDeferred.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/eth-block-tracker/tests/emptyFunction.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/eth-block-tracker/tests/recordCallsToSetTimeout.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "packages/eth-block-tracker/tests/setupAfterEnv.ts": { + "@typescript-eslint/consistent-type-definitions": { + "count": 1 + }, + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "packages/eth-block-tracker/tests/withBlockTracker.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "packages/eth-json-rpc-middleware/src/block-cache.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 20 + } + }, + "packages/eth-json-rpc-middleware/src/block-cache.ts": { + "id-denylist": { + "count": 4 + } + }, + "packages/eth-json-rpc-middleware/src/block-ref-rewrite.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "@typescript-eslint/unbound-method": { + "count": 3 + } + }, + "packages/eth-json-rpc-middleware/src/block-tracker-inspector.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 10 + } + }, + "packages/eth-json-rpc-middleware/src/fetch.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/eth-json-rpc-middleware/src/inflight-cache.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/eth-json-rpc-middleware/src/inflight-cache.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/eth-json-rpc-middleware/src/methods/wallet-request-execution-permissions.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/eth-json-rpc-middleware/src/methods/wallet-revoke-execution-permission.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/eth-json-rpc-middleware/src/providerAsMiddleware.test.ts": { + "@typescript-eslint/unbound-method": { + "count": 4 + } + }, + "packages/eth-json-rpc-middleware/src/retryOnEmpty.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "@typescript-eslint/naming-convention": { + "count": 4 + } + }, + "packages/eth-json-rpc-middleware/src/retryOnEmpty.ts": { + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "id-denylist": { + "count": 2 + } + }, + "packages/eth-json-rpc-middleware/src/utils/common.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/eth-json-rpc-middleware/src/utils/normalize.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/eth-json-rpc-middleware/src/utils/normalize.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/eth-json-rpc-middleware/src/utils/timeout.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + } + }, + "packages/eth-json-rpc-middleware/src/utils/validation.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/eth-json-rpc-middleware/src/utils/validation.ts": { + "id-length": { + "count": 1 + } + }, + "packages/eth-json-rpc-middleware/src/wallet.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 52 + } + }, + "packages/eth-json-rpc-middleware/src/wallet.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 5 + } + }, + "packages/eth-json-rpc-middleware/test/setupAfterEnv.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "id-length": { + "count": 1 + } + }, + "packages/eth-json-rpc-middleware/test/util/helpers.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/eth-json-rpc-provider/src/internal-provider.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/eth-json-rpc-provider/src/internal-provider.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/foundryup/src/cli.ts": { + "no-restricted-globals": { + "count": 1 + } + }, + "packages/foundryup/src/download.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/foundryup/src/extract.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "id-length": { + "count": 2 + } + }, + "packages/foundryup/src/foundryup.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "id-length": { + "count": 1 + }, + "n/no-sync": { + "count": 2 + } + }, + "packages/foundryup/src/index.ts": { + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "id-length": { + "count": 2 + } + }, + "packages/foundryup/src/options.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/foundryup/src/types.ts": { + "@typescript-eslint/naming-convention": { + "count": 4 + } + }, + "packages/foundryup/src/utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "id-denylist": { + "count": 2 + } + }, + "packages/foundryup/types/unzipper.d.ts": { + "import-x/no-unassigned-import": { + "count": 1 + } + }, + "packages/gas-fee-controller/src/GasFeeController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "import-x/namespace": { + "count": 2 + } + }, + "packages/gas-fee-controller/src/GasFeeController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 11 + }, + "@typescript-eslint/naming-convention": { + "count": 3 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 2 + }, + "id-length": { + "count": 1 + }, + "no-restricted-syntax": { + "count": 14 + } + }, + "packages/gas-fee-controller/src/gas-util.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "id-length": { + "count": 1 + } + }, + "packages/gator-permissions-controller/src/GatorPermissionsController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "id-denylist": { + "count": 2 + }, + "no-new": { + "count": 1 + } + }, + "packages/gator-permissions-controller/src/GatorPermissionsController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 16 + } + }, + "packages/gator-permissions-controller/src/decodePermission/decodePermission.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/gator-permissions-controller/src/decodePermission/utils.test.ts": { + "id-length": { + "count": 1 + } + }, + "packages/gator-permissions-controller/src/decodePermission/utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "id-length": { + "count": 1 + } + }, + "packages/gator-permissions-controller/src/test/mocks.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/json-rpc-engine/src/JsonRpcEngine.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 2 + }, + "no-restricted-syntax": { + "count": 1 + } + }, + "packages/json-rpc-engine/src/asV2Middleware.ts": { + "id-denylist": { + "count": 1 + } + }, + "packages/json-rpc-engine/src/createAsyncMiddleware.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/json-rpc-engine/src/mergeMiddleware.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + } + }, + "packages/json-rpc-engine/src/mergeMiddleware.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 50 + }, + "id-length": { + "count": 1 + } + }, + "packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "no-restricted-syntax": { + "count": 1 + } + }, + "packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 16 + } + }, + "packages/json-rpc-engine/src/v2/MiddlewareContext.ts": { + "@typescript-eslint/naming-convention": { + "count": 6 + }, + "no-restricted-syntax": { + "count": 1 + } + }, + "packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/json-rpc-engine/src/v2/compatibility-utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/naming-convention": { + "count": 2 + } + }, + "packages/json-rpc-engine/src/v2/createScaffoldMiddleware.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/json-rpc-engine/src/v2/utils.test.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "packages/json-rpc-engine/src/v2/utils.ts": { + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "id-denylist": { + "count": 4 + }, + "no-restricted-syntax": { + "count": 1 + } + }, + "packages/json-rpc-engine/tests/utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/json-rpc-middleware-stream/src/createEngineStream.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + } + }, + "packages/json-rpc-middleware-stream/src/index.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/prefer-promise-reject-errors": { + "count": 1 + }, + "no-empty-function": { + "count": 1 + } + }, + "packages/keyring-controller/jest.environment.js": { + "n/no-unsupported-features/node-builtins": { + "count": 1 + } + }, + "packages/keyring-controller/src/KeyringController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 8 + }, + "@typescript-eslint/no-misused-promises": { + "count": 1 + }, + "@typescript-eslint/unbound-method": { + "count": 19 + } + }, + "packages/keyring-controller/src/KeyringController.ts": { + "@typescript-eslint/naming-convention": { + "count": 10 + } + }, + "packages/keyring-controller/tests/mocks/mockEncryptor.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 8 + } + }, + "packages/keyring-controller/tests/mocks/mockErc4337Keyring.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/keyring-controller/tests/mocks/mockKeyring.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + } + }, + "packages/keyring-controller/tests/mocks/mockShallowKeyring.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/keyring-controller/tests/mocks/mockTransaction.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/logging-controller/src/LoggingController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "import-x/namespace": { + "count": 1 + } + }, + "packages/logging-controller/src/LoggingController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/message-manager/src/AbstractMessageManager.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + } + }, + "packages/message-manager/src/AbstractMessageManager.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 19 + }, + "id-denylist": { + "count": 2 + }, + "no-restricted-syntax": { + "count": 3 + } + }, + "packages/message-manager/src/DecryptMessageManager.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "import-x/no-unassigned-import": { + "count": 1 + } + }, + "packages/message-manager/src/utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/no-unused-vars": { + "count": 1 + }, + "id-length": { + "count": 1 + }, + "no-restricted-globals": { + "count": 1 + } + }, + "packages/messenger/src/Messenger.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 9 + }, + "id-length": { + "count": 1 + } + }, + "packages/messenger/src/Messenger.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 21 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 4 + } + }, + "packages/multichain-account-service/src/MultichainAccountService.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/naming-convention": { + "count": 4 + }, + "@typescript-eslint/unbound-method": { + "count": 1 + } + }, + "packages/multichain-account-service/src/MultichainAccountService.ts": { + "id-length": { + "count": 1 + } + }, + "packages/multichain-account-service/src/MultichainAccountWallet.test.ts": { + "id-length": { + "count": 2 + }, + "no-param-reassign": { + "count": 1 + } + }, + "packages/multichain-account-service/src/MultichainAccountWallet.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "id-length": { + "count": 1 + }, + "require-atomic-updates": { + "count": 2 + } + }, + "packages/multichain-account-service/src/providers/AccountProviderWrapper.ts": { + "no-restricted-syntax": { + "count": 2 + } + }, + "packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts": { + "no-negated-condition": { + "count": 1 + } + }, + "packages/multichain-account-service/src/providers/BtcAccountProvider.ts": { + "@typescript-eslint/naming-convention": { + "count": 2 + } + }, + "packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + } + }, + "packages/multichain-account-service/src/providers/EvmAccountProvider.ts": { + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/multichain-account-service/src/providers/SnapAccountProvider.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/multichain-account-service/src/providers/SolAccountProvider.ts": { + "@typescript-eslint/naming-convention": { + "count": 2 + }, + "id-length": { + "count": 1 + } + }, + "packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts": { + "no-negated-condition": { + "count": 1 + } + }, + "packages/multichain-account-service/src/providers/TrxAccountProvider.ts": { + "@typescript-eslint/naming-convention": { + "count": 2 + } + }, + "packages/multichain-account-service/src/providers/utils.ts": { + "@typescript-eslint/naming-convention": { + "count": 2 + } + }, + "packages/multichain-account-service/src/tests/accounts.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + } + }, + "packages/multichain-account-service/src/tests/providers.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/multichain-account-service/src/utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "id-denylist": { + "count": 1 + }, + "id-length": { + "count": 1 + } + }, + "packages/multichain-api-middleware/src/handlers/types.ts": { + "@typescript-eslint/naming-convention": { + "count": 2 + } + }, + "packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "id-denylist": { + "count": 1 + } + }, + "packages/multichain-api-middleware/src/handlers/wallet-createSession.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 2 + }, + "id-denylist": { + "count": 1 + } + }, + "packages/multichain-api-middleware/src/handlers/wallet-getSession.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/multichain-api-middleware/src/handlers/wallet-getSession.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "id-denylist": { + "count": 2 + } + }, + "packages/multichain-api-middleware/src/handlers/wallet-revokeSession.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "id-denylist": { + "count": 1 + }, + "no-negated-condition": { + "count": 1 + } + }, + "packages/multichain-api-middleware/src/middlewares/MultichainMiddlewareManager.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + } + }, + "packages/multichain-api-middleware/src/middlewares/MultichainSubscriptionManager.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/multichain-api-middleware/src/middlewares/MultichainSubscriptionManager.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + } + }, + "packages/multichain-api-middleware/src/middlewares/multichainMethodCallValidatorMiddleware.test.ts": { + "id-length": { + "count": 6 + } + }, + "packages/multichain-api-middleware/src/middlewares/multichainMethodCallValidatorMiddleware.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "id-length": { + "count": 4 + } + }, + "packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/multichain-network-controller/src/utils.ts": { + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts": { + "@typescript-eslint/unbound-method": { + "count": 1 + }, + "jest/unbound-method": { + "count": 1 + } + }, + "packages/multichain-transactions-controller/src/MultichainTransactionsController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/no-misused-promises": { + "count": 2 + } + }, + "packages/name-controller/src/NameController.test.ts": { + "@typescript-eslint/unbound-method": { + "count": 7 + } + }, + "packages/name-controller/src/NameController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 16 + }, + "@typescript-eslint/naming-convention": { + "count": 2 + } + }, + "packages/name-controller/src/providers/ens.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/name-controller/src/providers/ens.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/name-controller/src/providers/etherscan.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/name-controller/src/providers/etherscan.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/naming-convention": { + "count": 12 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/name-controller/src/providers/lens.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/name-controller/src/providers/lens.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/name-controller/src/providers/token.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/name-controller/src/providers/token.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/name-controller/src/types.ts": { + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/name-controller/src/util.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "jsdoc/require-returns": { + "count": 1 + } + }, + "packages/network-controller/src/NetworkController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 29 + }, + "@typescript-eslint/naming-convention": { + "count": 2 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 3 + }, + "no-negated-condition": { + "count": 1 + }, + "no-param-reassign": { + "count": 1 + } + }, + "packages/network-controller/src/create-auto-managed-network-client.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 12 + }, + "n/no-sync": { + "count": 1 + } + }, + "packages/network-controller/src/create-auto-managed-network-client.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 9 + } + }, + "packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/network-controller/src/create-network-client.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + }, + "no-restricted-globals": { + "count": 2 + } + }, + "packages/network-controller/src/rpc-service/rpc-service-chain.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/network-controller/src/rpc-service/rpc-service-chain.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 9 + } + }, + "packages/network-controller/src/rpc-service/rpc-service.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "no-restricted-globals": { + "count": 1 + } + }, + "packages/network-controller/src/rpc-service/rpc-service.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 9 + } + }, + "packages/network-controller/tests/NetworkController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 51 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "@typescript-eslint/unbound-method": { + "count": 1 + }, + "jest/unbound-method": { + "count": 1 + }, + "require-atomic-updates": { + "count": 1 + } + }, + "packages/network-controller/tests/helpers.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/network-controller/tests/network-client/block-hash-in-response.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/network-controller/tests/network-client/block-param.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/network-controller/tests/network-client/helpers.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 16 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/network-controller/tests/network-client/no-block-param.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/network-controller/tests/network-client/not-handled-by-middleware.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/network-controller/tests/network-client/rpc-failover.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/network-enablement-controller/src/NetworkEnablementController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 2 + }, + "id-length": { + "count": 7 + } + }, + "packages/network-enablement-controller/src/selectors.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 23 + }, + "id-length": { + "count": 7 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 31 + }, + "@typescript-eslint/no-misused-promises": { + "count": 1 + }, + "id-denylist": { + "count": 5 + }, + "id-length": { + "count": 8 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockServices.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 6 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/constants/notification-schema.ts": { + "@typescript-eslint/naming-convention": { + "count": 18 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/mocks/mockResponses.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 6 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/processors/process-api-notifications.test.ts": { + "id-length": { + "count": 1 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "id-length": { + "count": 6 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 8 + }, + "id-denylist": { + "count": 1 + }, + "id-length": { + "count": 4 + }, + "no-param-reassign": { + "count": 2 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/services/feature-announcements.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/services/feature-announcements.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/naming-convention": { + "count": 2 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 2 + }, + "id-length": { + "count": 3 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/services/notification-config-cache.ts": { + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/services/perp-notifications.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/services/perp-notifications.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "id-length": { + "count": 1 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/types/notification-api/notification-api.ts": { + "@typescript-eslint/naming-convention": { + "count": 18 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/types/notification-api/schema.ts": { + "@typescript-eslint/naming-convention": { + "count": 70 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/types/perps/schema.ts": { + "@typescript-eslint/naming-convention": { + "count": 9 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/utils/isVersionInBounds.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/notification-services-controller/src/NotificationServicesController/utils/should-auto-expire.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 6 + } + }, + "packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockServices.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/notification-services-controller/src/NotificationServicesPushController/mocks/mockResponse.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/notification-services-controller/src/NotificationServicesPushController/services/endpoints.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/notification-services-controller/src/NotificationServicesPushController/services/services.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + } + }, + "packages/notification-services-controller/src/NotificationServicesPushController/services/services.ts": { + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-data.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "id-denylist": { + "count": 1 + } + }, + "packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-message.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + }, + "@typescript-eslint/naming-convention": { + "count": 2 + }, + "id-length": { + "count": 9 + } + }, + "packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 10 + }, + "no-restricted-globals": { + "count": 3 + } + }, + "packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + }, + "require-atomic-updates": { + "count": 1 + } + }, + "packages/notification-services-controller/src/shared/is-onchain-notification.ts": { + "id-length": { + "count": 1 + } + }, + "packages/notification-services-controller/src/shared/to-raw-notification.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/permission-controller/src/Caveat.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 9 + } + }, + "packages/permission-controller/src/Caveat.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/permission-controller/src/PermissionController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 57 + }, + "no-new": { + "count": 1 + } + }, + "packages/permission-controller/src/PermissionController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 8 + }, + "@typescript-eslint/prefer-enum-initializers": { + "count": 4 + }, + "no-restricted-syntax": { + "count": 25 + } + }, + "packages/permission-controller/src/SubjectMetadataController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/permission-controller/src/SubjectMetadataController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 4 + }, + "no-restricted-syntax": { + "count": 4 + } + }, + "packages/permission-controller/src/errors.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/permission-controller/src/rpc-methods/requestPermissions.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/permission-controller/src/utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/permission-log-controller/src/PermissionLogController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "id-denylist": { + "count": 1 + } + }, + "packages/permission-log-controller/src/enums.ts": { + "@typescript-eslint/naming-convention": { + "count": 3 + } + }, + "packages/permission-log-controller/tests/PermissionLogController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "id-length": { + "count": 2 + } + }, + "packages/permission-log-controller/tests/helpers.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + }, + "@typescript-eslint/naming-convention": { + "count": 10 + } + }, + "packages/phishing-controller/src/BulkTokenScan.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/phishing-controller/src/CacheManager.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/phishing-controller/src/CacheManager.ts": { + "@typescript-eslint/naming-convention": { + "count": 3 + } + }, + "packages/phishing-controller/src/PathTrie.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/phishing-controller/src/PhishingController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/unbound-method": { + "count": 7 + }, + "jest/unbound-method": { + "count": 7 + } + }, + "packages/phishing-controller/src/PhishingController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 25 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 6 + } + }, + "packages/phishing-controller/src/PhishingDetector.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "id-length": { + "count": 4 + } + }, + "packages/phishing-controller/src/PhishingDetector.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/phishing-controller/src/tests/utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/phishing-controller/src/types.ts": { + "@typescript-eslint/naming-convention": { + "count": 4 + } + }, + "packages/phishing-controller/src/utils.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "import-x/namespace": { + "count": 5 + } + }, + "packages/phishing-controller/src/utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/polling-controller/src/AbstractPollingController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/polling-controller/src/BlockTrackerPollingController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "no-restricted-syntax": { + "count": 1 + } + }, + "packages/polling-controller/src/BlockTrackerPollingController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/polling-controller/src/StaticIntervalPollingController.test.ts": { + "id-denylist": { + "count": 1 + } + }, + "packages/polling-controller/src/StaticIntervalPollingController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/preferences-controller/src/PreferencesController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/preferences-controller/src/PreferencesController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 24 + }, + "no-param-reassign": { + "count": 3 + } + }, + "packages/profile-metrics-controller/src/ProfileMetricsController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 2 + } + }, + "packages/profile-metrics-controller/src/ProfileMetricsService.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + }, + "id-length": { + "count": 4 + }, + "no-new": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 2 + } + }, + "packages/profile-sync-controller/src/controllers/authentication/__fixtures__/mockServices.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/profile-sync-controller/src/controllers/authentication/mocks/mockResponses.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 6 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 24 + } + }, + "packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 13 + }, + "id-length": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockServices.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + } + }, + "packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/test-utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "id-length": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/__fixtures__/test-utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/controller-integration.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "id-length": { + "count": 3 + }, + "n/no-sync": { + "count": 3 + } + }, + "packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/controller-integration.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 4 + }, + "id-length": { + "count": 2 + }, + "no-negated-condition": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/setup-subscriptions.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/sync-utils.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/utils.ts": { + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 3 + } + }, + "packages/profile-sync-controller/src/controllers/user-storage/mocks/mockResponses.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 8 + } + }, + "packages/profile-sync-controller/src/controllers/user-storage/mocks/mockStorage.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "require-atomic-updates": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/controllers/user-storage/types.ts": { + "@typescript-eslint/naming-convention": { + "count": 4 + } + }, + "packages/profile-sync-controller/src/controllers/user-storage/utils.ts": { + "id-length": { + "count": 2 + } + }, + "packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + } + }, + "packages/profile-sync-controller/src/sdk/__fixtures__/test-utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/sdk/__fixtures__/userstorage.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 6 + } + }, + "packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-siwe.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/naming-convention": { + "count": 2 + } + }, + "packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 9 + } + }, + "packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "id-length": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 6 + }, + "@typescript-eslint/naming-convention": { + "count": 4 + }, + "id-length": { + "count": 5 + }, + "no-restricted-globals": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/types.ts": { + "@typescript-eslint/naming-convention": { + "count": 5 + } + }, + "packages/profile-sync-controller/src/sdk/authentication.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "no-new": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/sdk/authentication.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "id-denylist": { + "count": 2 + }, + "id-length": { + "count": 3 + } + }, + "packages/profile-sync-controller/src/sdk/errors.ts": { + "id-length": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/sdk/mocks/userstorage.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/profile-sync-controller/src/sdk/user-storage.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/sdk/user-storage.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "@typescript-eslint/naming-convention": { + "count": 3 + }, + "id-length": { + "count": 10 + } + }, + "packages/profile-sync-controller/src/sdk/utils/eip-6963-metamask-provider.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "no-restricted-globals": { + "count": 4 + } + }, + "packages/profile-sync-controller/src/sdk/utils/eip-6963-metamask-provider.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "id-length": { + "count": 2 + }, + "no-restricted-globals": { + "count": 3 + } + }, + "packages/profile-sync-controller/src/sdk/utils/messaging-signing-snap-requests.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "id-length": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/sdk/utils/validate-login-response.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/shared/encryption/cache.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/shared/encryption/constants.ts": { + "@typescript-eslint/naming-convention": { + "count": 2 + }, + "camelcase": { + "count": 2 + } + }, + "packages/profile-sync-controller/src/shared/encryption/encryption.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/profile-sync-controller/src/shared/encryption/encryption.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "camelcase": { + "count": 6 + }, + "id-length": { + "count": 8 + } + }, + "packages/profile-sync-controller/src/shared/encryption/utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "no-restricted-globals": { + "count": 2 + } + }, + "packages/profile-sync-controller/src/shared/utils/event-queue.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "id-length": { + "count": 1 + } + }, + "packages/rate-limit-controller/src/RateLimitController.test.ts": { + "no-new": { + "count": 1 + } + }, + "packages/rate-limit-controller/src/RateLimitController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "no-restricted-syntax": { + "count": 6 + } + }, + "packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "promise/param-names": { + "count": 1 + } + }, + "packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "no-restricted-syntax": { + "count": 1 + } + }, + "packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/unbound-method": { + "count": 5 + } + }, + "packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/sample-controllers/src/sample-gas-prices-controller.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/no-misused-promises": { + "count": 1 + } + }, + "packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts": { + "@typescript-eslint/no-misused-promises": { + "count": 4 + } + }, + "packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/sample-controllers/src/sample-petnames-controller.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/seedless-onboarding-controller/jest.environment.js": { + "n/no-unsupported-features/node-builtins": { + "count": 1 + } + }, + "packages/seedless-onboarding-controller/src/SecretMetadata.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "id-length": { + "count": 1 + } + }, + "packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 14 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "@typescript-eslint/unbound-method": { + "count": 16 + }, + "id-length": { + "count": 1 + }, + "jest/unbound-method": { + "count": 1 + }, + "no-new": { + "count": 1 + } + }, + "packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 23 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 3 + }, + "no-negated-condition": { + "count": 1 + } + }, + "packages/seedless-onboarding-controller/src/assertions.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/seedless-onboarding-controller/src/types.ts": { + "@typescript-eslint/naming-convention": { + "count": 4 + } + }, + "packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/seedless-onboarding-controller/tests/__fixtures__/topfClient.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + } + }, + "packages/seedless-onboarding-controller/tests/mocks/toprf.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/seedless-onboarding-controller/tests/mocks/toprfEncryptor.ts": { + "@typescript-eslint/naming-convention": { + "count": 3 + } + }, + "packages/seedless-onboarding-controller/tests/mocks/vaultEncryptor.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + }, + "@typescript-eslint/naming-convention": { + "count": 2 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 2 + } + }, + "packages/selected-network-controller/src/SelectedNetworkController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + } + }, + "packages/selected-network-controller/src/SelectedNetworkMiddleware.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/selected-network-controller/tests/SelectedNetworkController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/unbound-method": { + "count": 4 + } + }, + "packages/shield-controller/src/ShieldController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + } + }, + "packages/shield-controller/src/ShieldController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 8 + } + }, + "packages/shield-controller/src/backend.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/shield-controller/src/backend.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + } + }, + "packages/shield-controller/src/polling-with-policy.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/shield-controller/src/polling-with-policy.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "@typescript-eslint/unbound-method": { + "count": 1 + } + }, + "packages/shield-controller/tests/mocks/backend.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/shield-controller/tests/utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/signature-controller/src/SignatureController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + } + }, + "packages/signature-controller/src/SignatureController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 15 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "no-case-declarations": { + "count": 3 + } + }, + "packages/signature-controller/src/utils/decoding-api.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/signature-controller/src/utils/decoding-api.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "packages/signature-controller/src/utils/delegations.test.ts": { + "@typescript-eslint/unbound-method": { + "count": 1 + } + }, + "packages/signature-controller/src/utils/normalize.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/no-unused-vars": { + "count": 1 + }, + "id-length": { + "count": 1 + }, + "no-restricted-globals": { + "count": 1 + } + }, + "packages/signature-controller/src/utils/validation.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/signature-controller/src/utils/validation.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 7 + }, + "@typescript-eslint/no-base-to-string": { + "count": 1 + }, + "@typescript-eslint/no-unused-vars": { + "count": 2 + }, + "id-length": { + "count": 2 + } + }, + "packages/storage-service/src/InMemoryStorageAdapter.test.ts": { + "id-denylist": { + "count": 1 + }, + "require-atomic-updates": { + "count": 1 + } + }, + "packages/storage-service/src/StorageService.test.ts": { + "@typescript-eslint/unbound-method": { + "count": 8 + } + }, + "packages/subscription-controller/src/SubscriptionController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 4 } }, - "packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts": { - "@typescript-eslint/no-misused-promises": { - "count": 2 + "packages/subscription-controller/src/SubscriptionController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 14 + }, + "id-length": { + "count": 13 } }, - "packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts": { - "@typescript-eslint/no-misused-promises": { + "packages/subscription-controller/src/SubscriptionService.test.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 3 } }, - "packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts": { - "@typescript-eslint/no-misused-promises": { + "packages/subscription-controller/src/SubscriptionService.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 2 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "id-length": { + "count": 1 } }, - "packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts": { - "@typescript-eslint/no-misused-promises": { - "count": 2 + "packages/token-search-discovery-controller/src/token-discovery-api-service/token-discovery-api-service.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 } }, - "packages/assets-controllers/src/NftController.test.ts": { - "import-x/namespace": { - "count": 9 + "packages/token-search-discovery-controller/src/types.ts": { + "@typescript-eslint/naming-convention": { + "count": 18 } }, - "packages/assets-controllers/src/NftController.ts": { - "@typescript-eslint/no-misused-promises": { + "packages/transaction-pay-controller/src/TransactionPayController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 2 + }, + "no-new": { + "count": 1 } }, - "packages/assets-controllers/src/NftDetectionController.test.ts": { - "import-x/namespace": { + "packages/transaction-pay-controller/src/TransactionPayController.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 6 - } - }, - "packages/assets-controllers/src/Standards/ERC20Standard.test.ts": { - "jest/no-commented-out-tests": { + }, + "no-new": { "count": 1 } }, - "packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts": { - "import-x/no-named-as-default-member": { + "packages/transaction-pay-controller/src/actions/update-payment-token.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 1 } }, - "packages/assets-controllers/src/TokenBalancesController.ts": { - "@typescript-eslint/no-misused-promises": { + "packages/transaction-pay-controller/src/helpers/QuoteRefresher.test.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 1 + }, + "no-new": { + "count": 7 } }, - "packages/assets-controllers/src/TokenDetectionController.ts": { - "@typescript-eslint/no-misused-promises": { + "packages/transaction-pay-controller/src/helpers/QuoteRefresher.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 5 } }, - "packages/assets-controllers/src/TokenListController.test.ts": { - "import-x/namespace": { - "count": 7 + "packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 } }, - "packages/assets-controllers/src/TokensController.test.ts": { - "import-x/namespace": { - "count": 1 + "packages/transaction-pay-controller/src/strategy/bridge/BridgeStrategy.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 } }, - "packages/assets-controllers/src/TokensController.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 + "packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts": { + "id-length": { + "count": 6 } }, - "packages/assets-controllers/src/multicall.test.ts": { - "@typescript-eslint/prefer-promise-reject-errors": { + "packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 2 + }, + "id-length": { "count": 2 } }, - "packages/base-controller/src/BaseController.test.ts": { - "import-x/namespace": { - "count": 13 + "packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 } }, - "packages/composable-controller/src/ComposableController.test.ts": { - "import-x/namespace": { + "packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 3 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 } }, - "packages/controller-utils/jest.environment.js": { - "n/prefer-global/text-decoder": { + "packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 6 + }, + "@typescript-eslint/prefer-nullish-coalescing": { "count": 1 }, - "n/prefer-global/text-encoder": { + "id-length": { + "count": 10 + }, + "require-atomic-updates": { + "count": 5 + } + }, + "packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 1 }, - "no-shadow": { + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "id-length": { + "count": 4 + } + }, + "packages/transaction-pay-controller/src/strategy/test/TestStrategy.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 2 } }, - "packages/controller-utils/src/siwe.ts": { - "@typescript-eslint/no-unused-vars": { + "packages/transaction-pay-controller/src/tests/messenger-mock.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 1 } }, - "packages/controller-utils/src/util.test.ts": { - "import-x/no-named-as-default": { + "packages/transaction-pay-controller/src/utils/gas.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 1 }, - "promise/param-names": { - "count": 2 + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 6 + }, + "id-length": { + "count": 1 } }, - "packages/controller-utils/src/util.ts": { - "@typescript-eslint/no-base-to-string": { + "packages/transaction-pay-controller/src/utils/quotes.test.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 1 - }, - "@typescript-eslint/no-unused-vars": { + } + }, + "packages/transaction-pay-controller/src/utils/quotes.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 3 }, - "@typescript-eslint/prefer-promise-reject-errors": { + "id-length": { + "count": 1 + } + }, + "packages/transaction-pay-controller/src/utils/required-tokens.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 1 }, - "promise/param-names": { - "count": 3 + "no-negated-condition": { + "count": 1 } }, - "packages/eip-5792-middleware/src/hooks/processSendCalls.ts": { - "@typescript-eslint/no-misused-promises": { + "packages/transaction-pay-controller/src/utils/source-amounts.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, + "id-length": { "count": 1 } }, - "packages/eth-block-tracker/tests/recordCallsToSetTimeout.ts": { - "@typescript-eslint/no-explicit-any": { + "packages/transaction-pay-controller/src/utils/token.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "id-length": { "count": 1 } }, - "packages/eth-block-tracker/tests/setupAfterEnv.ts": { - "@typescript-eslint/consistent-type-definitions": { + "packages/transaction-pay-controller/src/utils/totals.ts": { + "@typescript-eslint/naming-convention": { "count": 1 }, - "@typescript-eslint/no-explicit-any": { - "count": 3 + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "id-length": { + "count": 1 } }, - "packages/eth-block-tracker/tests/withBlockTracker.ts": { - "@typescript-eslint/no-explicit-any": { + "packages/transaction-pay-controller/src/utils/transaction.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 10 + }, + "id-length": { "count": 1 } }, - "packages/gas-fee-controller/src/GasFeeController.test.ts": { - "import-x/namespace": { - "count": 2 + "packages/user-operation-controller/src/UserOperationController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 6 + }, + "@typescript-eslint/unbound-method": { + "count": 9 + }, + "no-new": { + "count": 1 } }, - "packages/json-rpc-middleware-stream/src/index.test.ts": { + "packages/user-operation-controller/src/UserOperationController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 19 + }, + "@typescript-eslint/naming-convention": { + "count": 3 + }, "@typescript-eslint/prefer-promise-reject-errors": { "count": 1 }, - "no-empty-function": { - "count": 1 + "jsdoc/require-returns": { + "count": 2 + }, + "require-atomic-updates": { + "count": 3 } }, - "packages/keyring-controller/jest.environment.js": { - "n/no-unsupported-features/node-builtins": { + "packages/user-operation-controller/src/helpers/Bundler.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "jsdoc/require-returns": { "count": 1 } }, - "packages/keyring-controller/src/KeyringController.test.ts": { - "@typescript-eslint/no-misused-promises": { + "packages/user-operation-controller/src/helpers/Bundler.ts": { + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { "count": 1 } }, - "packages/keyring-controller/src/KeyringController.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 + "packages/user-operation-controller/src/helpers/PendingUserOperationTracker.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 11 + }, + "@typescript-eslint/unbound-method": { + "count": 4 } }, - "packages/logging-controller/src/LoggingController.test.ts": { - "import-x/namespace": { + "packages/user-operation-controller/src/helpers/PendingUserOperationTracker.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 6 + }, + "@typescript-eslint/naming-convention": { + "count": 3 + }, + "require-atomic-updates": { + "count": 5 + } + }, + "packages/user-operation-controller/src/helpers/SnapSmartContractAccount.test.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 1 } }, - "packages/message-manager/src/utils.ts": { - "@typescript-eslint/no-unused-vars": { + "packages/user-operation-controller/src/utils/gas-fees.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 2 + }, + "require-atomic-updates": { "count": 1 } }, - "packages/multichain-transactions-controller/src/MultichainTransactionsController.ts": { - "@typescript-eslint/no-misused-promises": { + "packages/user-operation-controller/src/utils/gas.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "@typescript-eslint/unbound-method": { "count": 2 } }, - "packages/name-controller/src/util.ts": { - "jsdoc/require-returns": { + "packages/user-operation-controller/src/utils/gas.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 1 } }, - "packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts": { - "@typescript-eslint/no-misused-promises": { + "packages/user-operation-controller/src/utils/transaction.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 1 } }, - "packages/phishing-controller/src/utils.test.ts": { - "import-x/namespace": { - "count": 5 + "packages/user-operation-controller/src/utils/validation.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/naming-convention": { + "count": 2 } }, - "packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts": { - "promise/param-names": { + "packages/user-operation-controller/src/utils/validation.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 8 + }, + "@typescript-eslint/naming-convention": { "count": 1 } }, - "packages/sample-controllers/src/sample-gas-prices-controller.ts": { - "@typescript-eslint/no-misused-promises": { + "scripts/create-package/cli.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + } + }, + "scripts/create-package/cli.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 1 } }, - "packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts": { - "@typescript-eslint/no-misused-promises": { - "count": 4 + "scripts/create-package/fs-utils.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 } }, - "packages/seedless-onboarding-controller/jest.environment.js": { - "n/no-unsupported-features/node-builtins": { + "scripts/create-package/index.test.ts": { + "import-x/no-unassigned-import": { "count": 1 } }, - "packages/signature-controller/src/utils/normalize.ts": { - "@typescript-eslint/no-unused-vars": { + "scripts/create-package/utils.test.ts": { + "import-x/no-named-as-default-member": { + "count": 2 + } + }, + "scripts/create-package/utils.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 1 } }, - "packages/signature-controller/src/utils/validation.ts": { - "@typescript-eslint/no-base-to-string": { + "scripts/generate-method-action-types.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "n/no-sync": { "count": 1 }, - "@typescript-eslint/no-unused-vars": { - "count": 2 + "no-negated-condition": { + "count": 1 } }, - "packages/user-operation-controller/src/UserOperationController.ts": { - "@typescript-eslint/prefer-promise-reject-errors": { + "scripts/generate-preview-build-message.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 1 - }, - "jsdoc/require-returns": { - "count": 2 } }, - "packages/user-operation-controller/src/helpers/Bundler.test.ts": { - "jsdoc/require-returns": { + "scripts/lint-teams-json.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 1 } }, - "scripts/create-package/utils.test.ts": { - "import-x/no-named-as-default-member": { + "scripts/update-readme-content.ts": { + "@typescript-eslint/explicit-function-return-type": { "count": 2 } }, "tests/fake-block-tracker.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 3 + }, "no-empty-function": { "count": 1 } }, "tests/fake-provider.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 5 + }, "@typescript-eslint/prefer-promise-reject-errors": { "count": 1 } }, + "tests/helpers.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + }, + "@typescript-eslint/naming-convention": { + "count": 1 + }, + "no-param-reassign": { + "count": 1 + } + }, + "tests/mock-network.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 1 + } + }, + "tests/setup.ts": { + "import-x/no-unassigned-import": { + "count": 1 + } + }, + "tests/setupAfterEnv/index.ts": { + "import-x/no-unassigned-import": { + "count": 2 + } + }, + "tests/setupAfterEnv/matchers.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 4 + }, + "id-length": { + "count": 2 + } + }, "tests/setupAfterEnv/nock.ts": { "import-x/no-named-as-default-member": { "count": 3 } + }, + "types/global.d.ts": { + "@typescript-eslint/naming-convention": { + "count": 1 + } + }, + "yarn.config.cjs": { + "no-restricted-syntax": { + "count": 2 + } } } diff --git a/eslint.config.mjs b/eslint.config.mjs index 423708baed6..34190be40b7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,34 +17,17 @@ const config = createConfig([ 'scripts/create-package/package-template/**', ], }, + { + linterOptions: { + reportUnusedDisableDirectives: 'error', + }, + }, { rules: { - // Left disabled because various properties throughough this repo are snake_case because the - // names come from external sources or must comply with standards - // e.g. `txreceipt_status`, `signTypedData_v4`, `token_id` - camelcase: 'off', - 'id-length': 'off', - - // TODO: re-enble most of these rules - 'function-paren-newline': 'off', - 'id-denylist': 'off', - 'implicit-arrow-linebreak': 'off', - 'import-x/no-anonymous-default-export': 'off', - 'import-x/no-unassigned-import': 'off', - 'lines-around-comment': 'off', - 'no-async-promise-executor': 'off', - 'no-case-declarations': 'off', - 'no-invalid-this': 'off', - 'no-negated-condition': 'off', - 'no-new': 'off', - 'no-param-reassign': 'off', - 'no-restricted-syntax': 'off', - radix: 'off', - 'require-atomic-updates': 'off', - 'jsdoc/match-description': [ - 'off', - { matchDescription: '^[A-Z`\\d_][\\s\\S]*[.?!`>)}]$' }, - ], + // TODO: Re-enable this rule + // Enabling it with error suppression breaks `--fix`, because the autofixer for this rule + // does not work very well. + 'jsdoc/require-jsdoc': 'off', }, settings: { jsdoc: { @@ -62,10 +45,6 @@ const config = createConfig([ 'scripts/create-package/**/*.ts', ], extends: [nodejs], - rules: { - // TODO: Re-enable this - 'n/no-sync': 'off', - }, }, { files: ['**/*.test.{js,ts}', '**/tests/**/*.{js,ts}'], @@ -76,9 +55,6 @@ const config = createConfig([ 'jest/no-alias-methods': 'error', 'jest/no-commented-out-tests': 'error', 'jest/no-disabled-tests': 'error', - - // TODO: Re-enable this rule - 'jest/unbound-method': 'off', }, settings: { node: { @@ -114,14 +90,6 @@ const config = createConfig([ }, }, rules: { - // These rules have been customized from their defaults. - '@typescript-eslint/switch-exhaustiveness-check': [ - 'error', - { - considerDefaultExhaustiveForUnions: true, - }, - ], - // TODO: Disable in `eslint-config-typescript`, tracked here: https://github.com/MetaMask/eslint-config/issues/413 '@typescript-eslint/no-unnecessary-type-arguments': 'off', @@ -136,22 +104,11 @@ const config = createConfig([ // TODO: auto-fix breaks stuff '@typescript-eslint/promise-function-async': 'off', - // TODO: Re-enable this rule - // Enabling it with error suppression breaks `--fix`, because the autofixer for this rule - // does not work very well. + // TODO: Re-enable these rules + // Enabling them with error suppression breaks `--fix`, because the autofixer for these rules + // do not work very well. 'jsdoc/check-tag-names': 'off', - - // TODO: re-enable most of these rules - '@typescript-eslint/naming-convention': 'off', - '@typescript-eslint/no-unnecessary-type-assertion': 'off', - '@typescript-eslint/unbound-method': 'off', - '@typescript-eslint/prefer-enum-initializers': 'off', - '@typescript-eslint/prefer-nullish-coalescing': 'off', - '@typescript-eslint/prefer-optional-chain': 'off', - '@typescript-eslint/prefer-reduce-type-parameter': 'off', - 'no-restricted-syntax': 'off', - 'no-restricted-globals': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', + 'jsdoc/require-jsdoc': 'off', }, }, { diff --git a/jest.config.packages.js b/jest.config.packages.js index b3406afaf17..55c7d9aeeb0 100644 --- a/jest.config.packages.js +++ b/jest.config.packages.js @@ -78,7 +78,7 @@ module.exports = { // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module // Here we ensure that Jest resolves `@metamask/*` imports to the uncompiled source code for packages that live in this repo. - // NOTE: This must be synchronized with the `paths` option in `tsconfig.packages.json`. + // NOTE: This must be synchronized with the `paths` option in `tsconfig.base.json`. moduleNameMapper: { '^@metamask/json-rpc-engine/v2$': [ '/../json-rpc-engine/src/v2/index.ts', diff --git a/package.json b/package.json index c77a6657521..4248592ae64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "687.0.0", + "version": "714.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { @@ -56,22 +56,22 @@ "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/create-release-branch": "^4.1.3", - "@metamask/eslint-config": "^14.1.0", - "@metamask/eslint-config-jest": "^14.1.0", - "@metamask/eslint-config-nodejs": "^14.0.0", - "@metamask/eslint-config-typescript": "^14.1.0", + "@metamask/eslint-config": "^15.0.0", + "@metamask/eslint-config-jest": "^15.0.0", + "@metamask/eslint-config-nodejs": "^15.0.0", + "@metamask/eslint-config-typescript": "^15.0.0", "@metamask/eth-block-tracker": "^15.0.0", "@metamask/eth-json-rpc-provider": "^6.0.0", "@metamask/json-rpc-engine": "^10.2.0", - "@metamask/network-controller": "^26.0.0", + "@metamask/network-controller": "^27.0.0", "@metamask/utils": "^11.8.1", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.191", "@types/node": "^16.18.54", "@types/semver": "^7", - "@typescript-eslint/eslint-plugin": "^8.7.0", - "@typescript-eslint/parser": "^8.7.0", + "@typescript-eslint/eslint-plugin": "^8.48.0", + "@typescript-eslint/parser": "^8.48.0", "@yarnpkg/types": "^4.0.0", "babel-jest": "^29.7.0", "depcheck": "^1.4.7", @@ -98,7 +98,7 @@ "simple-git-hooks": "^2.8.0", "tsx": "^4.20.5", "typescript": "~5.3.3", - "typescript-eslint": "^8.7.0", + "typescript-eslint": "^8.48.0", "yargs": "^17.7.2" }, "packageManager": "yarn@4.10.3", diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index 8ae4f22d31c..a0256297215 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/accounts-controller` (^35.0.0) + - `@metamask/keyring-controller` (^25.0.0) + - `@metamask/multichain-account-service` (^4.0.0) + - `@metamask/profile-sync-controller` (^27.0.0) + - `@metamask/snaps-controllers` (^14.0.1) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [4.0.0] ### Changed diff --git a/packages/account-tree-controller/package.json b/packages/account-tree-controller/package.json index 4b994659bf0..f478f3bed42 100644 --- a/packages/account-tree-controller/package.json +++ b/packages/account-tree-controller/package.json @@ -48,8 +48,13 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/accounts-controller": "^35.0.0", "@metamask/base-controller": "^9.0.0", + "@metamask/keyring-controller": "^25.0.0", "@metamask/messenger": "^0.3.0", + "@metamask/multichain-account-service": "^4.0.0", + "@metamask/profile-sync-controller": "^27.0.0", + "@metamask/snaps-controllers": "^14.0.1", "@metamask/snaps-sdk": "^9.0.0", "@metamask/snaps-utils": "^11.0.0", "@metamask/superstruct": "^3.1.0", @@ -59,14 +64,9 @@ }, "devDependencies": { "@metamask/account-api": "^0.12.0", - "@metamask/accounts-controller": "^35.0.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/keyring-api": "^21.0.0", - "@metamask/keyring-controller": "^25.0.0", - "@metamask/multichain-account-service": "^4.0.0", - "@metamask/profile-sync-controller": "^27.0.0", "@metamask/providers": "^22.1.0", - "@metamask/snaps-controllers": "^14.0.1", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -78,13 +78,7 @@ "webextension-polyfill": "^0.12.0" }, "peerDependencies": { - "@metamask/account-api": "^0.12.0", - "@metamask/accounts-controller": "^35.0.0", - "@metamask/keyring-controller": "^25.0.0", - "@metamask/multichain-account-service": "^4.0.0", - "@metamask/profile-sync-controller": "^27.0.0", "@metamask/providers": "^22.0.0", - "@metamask/snaps-controllers": "^14.0.0", "webextension-polyfill": "^0.10.0 || ^0.11.0 || ^0.12.0" }, "engines": { diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index d98b819b97d..1c5e0fa15ff 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -1,4 +1,8 @@ -import type { AccountWalletId, Bip44Account } from '@metamask/account-api'; +import type { + AccountGroupId, + AccountWalletId, + Bip44Account, +} from '@metamask/account-api'; import { AccountGroupType, AccountWalletType, @@ -6,7 +10,6 @@ import { toAccountWalletId, toMultichainAccountGroupId, toMultichainAccountWalletId, - type AccountGroupId, } from '@metamask/account-api'; import type { AccountId } from '@metamask/accounts-controller'; import { deriveStateFromMetadata } from '@metamask/base-controller'; @@ -36,7 +39,7 @@ import type { BackupAndSyncAnalyticsEventPayload } from './backup-and-sync/analy import { BackupAndSyncService } from './backup-and-sync/service'; import { isAccountGroupNameUnique } from './group'; import { getAccountWalletNameFromKeyringType } from './rules/keyring'; -import { type AccountTreeControllerState } from './types'; +import type { AccountTreeControllerState } from './types'; import { getAccountTreeControllerMessenger, getRootMessenger, diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 6d8fe80ab98..960a4d842b1 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -7,7 +7,7 @@ import type { AccountGroupType, } from '@metamask/account-api'; import type { MultichainAccountWalletStatus } from '@metamask/account-api'; -import { type AccountId } from '@metamask/accounts-controller'; +import type { AccountId } from '@metamask/accounts-controller'; import type { StateMetadata } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { TraceCallback } from '@metamask/controller-utils'; @@ -40,7 +40,7 @@ import type { AccountTreeControllerMessenger, AccountTreeControllerState, } from './types'; -import { type AccountWalletObject, type AccountWalletObjectOf } from './wallet'; +import type { AccountWalletObject, AccountWalletObjectOf } from './wallet'; export const controllerName = 'AccountTreeController'; @@ -198,9 +198,8 @@ export class AccountTreeController extends BaseController< // Initialize backup and sync config this.#backupAndSyncConfig = { emitAnalyticsEventFn: (event: BackupAndSyncEmitAnalyticsEventParams) => { - return ( - config?.backupAndSync?.onBackupAndSyncEvent && - config.backupAndSync.onBackupAndSyncEvent(formatAnalyticsEvent(event)) + return config?.backupAndSync?.onBackupAndSyncEvent?.( + formatAnalyticsEvent(event), ); }, }; @@ -486,7 +485,7 @@ export class AccountTreeController extends BaseController< for (const id of group.accounts) { const account = this.messenger.call('AccountsController:getAccount', id); - if (!account || !account.metadata.name.length) { + if (!account?.metadata.name.length) { continue; } @@ -1153,7 +1152,7 @@ export class AccountTreeController extends BaseController< const selectedAccount = this.messenger.call( 'AccountsController:getSelectedMultichainAccount', ); - if (selectedAccount && selectedAccount.id) { + if (selectedAccount?.id) { const accountMapping = this.#accountIdToContext.get(selectedAccount.id); if (accountMapping) { const { groupId } = accountMapping; diff --git a/packages/account-tree-controller/src/backup-and-sync/analytics/segment.test.ts b/packages/account-tree-controller/src/backup-and-sync/analytics/segment.test.ts index cf39bf57b31..f7051247a63 100644 --- a/packages/account-tree-controller/src/backup-and-sync/analytics/segment.test.ts +++ b/packages/account-tree-controller/src/backup-and-sync/analytics/segment.test.ts @@ -1,9 +1,8 @@ -import { - BackupAndSyncAnalyticsEvent, - formatAnalyticsEvent, - type BackupAndSyncAnalyticsAction, - type BackupAndSyncEmitAnalyticsEventParams, - type BackupAndSyncAnalyticsEventPayload, +import { BackupAndSyncAnalyticsEvent, formatAnalyticsEvent } from './segment'; +import type { + BackupAndSyncAnalyticsAction, + BackupAndSyncEmitAnalyticsEventParams, + BackupAndSyncAnalyticsEventPayload, } from './segment'; describe('BackupAndSyncAnalytics - Segment', () => { diff --git a/packages/account-tree-controller/src/backup-and-sync/syncing/group.ts b/packages/account-tree-controller/src/backup-and-sync/syncing/group.ts index 58afed11bcc..770aa2eea64 100644 --- a/packages/account-tree-controller/src/backup-and-sync/syncing/group.ts +++ b/packages/account-tree-controller/src/backup-and-sync/syncing/group.ts @@ -5,10 +5,10 @@ import type { AccountWalletEntropyObject } from '../../wallet'; import type { BackupAndSyncAnalyticsAction } from '../analytics'; import { BackupAndSyncAnalyticsEvent } from '../analytics'; import type { ProfileId } from '../authentication'; -import { - UserStorageSyncedWalletGroupSchema, - type BackupAndSyncContext, - type UserStorageSyncedWalletGroup, +import { UserStorageSyncedWalletGroupSchema } from '../types'; +import type { + BackupAndSyncContext, + UserStorageSyncedWalletGroup, } from '../types'; import { pushGroupToUserStorage, diff --git a/packages/account-tree-controller/src/backup-and-sync/syncing/wallet.ts b/packages/account-tree-controller/src/backup-and-sync/syncing/wallet.ts index c5392b04ad4..a41be46fe91 100644 --- a/packages/account-tree-controller/src/backup-and-sync/syncing/wallet.ts +++ b/packages/account-tree-controller/src/backup-and-sync/syncing/wallet.ts @@ -3,11 +3,8 @@ import { backupAndSyncLogger } from '../../logger'; import type { AccountWalletEntropyObject } from '../../wallet'; import { BackupAndSyncAnalyticsEvent } from '../analytics'; import type { ProfileId } from '../authentication'; -import { - UserStorageSyncedWalletSchema, - type BackupAndSyncContext, - type UserStorageSyncedWallet, -} from '../types'; +import { UserStorageSyncedWalletSchema } from '../types'; +import type { BackupAndSyncContext, UserStorageSyncedWallet } from '../types'; import { pushWalletToUserStorage } from '../user-storage/network-operations'; /** diff --git a/packages/account-tree-controller/src/backup-and-sync/types.ts b/packages/account-tree-controller/src/backup-and-sync/types.ts index 3dce42f90cd..60e8ce061ca 100644 --- a/packages/account-tree-controller/src/backup-and-sync/types.ts +++ b/packages/account-tree-controller/src/backup-and-sync/types.ts @@ -6,15 +6,14 @@ import type { } from '@metamask/account-api'; import type { TraceCallback } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import type { Infer } from '@metamask/superstruct'; import { object, string, boolean, number, optional, - type Struct, } from '@metamask/superstruct'; +import type { Infer, Struct } from '@metamask/superstruct'; import type { BackupAndSyncEmitAnalyticsEventParams } from './analytics'; import type { AccountTreeController } from '../AccountTreeController'; diff --git a/packages/account-tree-controller/src/backup-and-sync/utils/controller.test.ts b/packages/account-tree-controller/src/backup-and-sync/utils/controller.test.ts index 76100ee92bc..ad06c79566e 100644 --- a/packages/account-tree-controller/src/backup-and-sync/utils/controller.test.ts +++ b/packages/account-tree-controller/src/backup-and-sync/utils/controller.test.ts @@ -5,9 +5,9 @@ import { getLocalGroupsForEntropyWallet, createStateSnapshot, restoreStateFromSnapshot, - type StateSnapshot, getLocalGroupForEntropyWallet, } from './controller'; +import type { StateSnapshot } from './controller'; import type { AccountTreeController } from '../../AccountTreeController'; import type { AccountWalletEntropyObject, diff --git a/packages/account-tree-controller/src/group.ts b/packages/account-tree-controller/src/group.ts index be62de79faa..7eda76d8d73 100644 --- a/packages/account-tree-controller/src/group.ts +++ b/packages/account-tree-controller/src/group.ts @@ -1,6 +1,6 @@ -import { - type AccountGroupType, - type MultichainAccountGroupId, +import type { + AccountGroupType, + MultichainAccountGroupId, } from '@metamask/account-api'; import type { AccountGroupId } from '@metamask/account-api'; import type { AccountId } from '@metamask/accounts-controller'; @@ -8,10 +8,10 @@ import { AnyAccountType, BtcAccountType, EthAccountType, - type KeyringAccountType, SolAccountType, TrxAccountType, } from '@metamask/keyring-api'; +import type { KeyringAccountType } from '@metamask/keyring-api'; import type { UpdatableField, ExtractFieldValues } from './type-utils'; import type { AccountTreeControllerState } from './types'; diff --git a/packages/account-tree-controller/src/rules/entropy.ts b/packages/account-tree-controller/src/rules/entropy.ts index 44b944bae68..392652213cd 100644 --- a/packages/account-tree-controller/src/rules/entropy.ts +++ b/packages/account-tree-controller/src/rules/entropy.ts @@ -10,7 +10,8 @@ import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { AccountGroupObjectOf } from '../group'; -import { BaseRule, type Rule, type RuleResult } from '../rule'; +import { BaseRule } from '../rule'; +import type { Rule, RuleResult } from '../rule'; import type { AccountWalletObjectOf } from '../wallet'; export class EntropyRule diff --git a/packages/account-tree-controller/src/rules/keyring.ts b/packages/account-tree-controller/src/rules/keyring.ts index 9aa57536565..881e8d86d74 100644 --- a/packages/account-tree-controller/src/rules/keyring.ts +++ b/packages/account-tree-controller/src/rules/keyring.ts @@ -5,7 +5,8 @@ import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { AccountGroupObjectOf } from '../group'; -import { BaseRule, type Rule, type RuleResult } from '../rule'; +import { BaseRule } from '../rule'; +import type { Rule, RuleResult } from '../rule'; import type { AccountWalletObjectOf } from '../wallet'; /** diff --git a/packages/account-tree-controller/src/rules/snap.ts b/packages/account-tree-controller/src/rules/snap.ts index 4a9a272ef46..8787c775d3d 100644 --- a/packages/account-tree-controller/src/rules/snap.ts +++ b/packages/account-tree-controller/src/rules/snap.ts @@ -6,7 +6,8 @@ import type { SnapId } from '@metamask/snaps-sdk'; import { stripSnapPrefix } from '@metamask/snaps-utils'; import { getAccountGroupPrefixFromKeyringType } from './keyring'; -import { BaseRule, type Rule, type RuleResult } from '../rule'; +import { BaseRule } from '../rule'; +import type { Rule, RuleResult } from '../rule'; import type { AccountWalletObjectOf } from '../wallet'; /** diff --git a/packages/account-tree-controller/src/types.ts b/packages/account-tree-controller/src/types.ts index da288a1b565..6b94bfcc8ab 100644 --- a/packages/account-tree-controller/src/types.ts +++ b/packages/account-tree-controller/src/types.ts @@ -9,9 +9,9 @@ import type { AccountsControllerSelectedAccountChangeEvent, AccountsControllerSetSelectedAccountAction, } from '@metamask/accounts-controller'; -import { - type ControllerGetStateAction, - type ControllerStateChangeEvent, +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import type { KeyringControllerGetStateAction } from '@metamask/keyring-controller'; diff --git a/packages/account-tree-controller/src/wallet.ts b/packages/account-tree-controller/src/wallet.ts index fdf965ac86b..fdfc46270f5 100644 --- a/packages/account-tree-controller/src/wallet.ts +++ b/packages/account-tree-controller/src/wallet.ts @@ -1,4 +1,4 @@ -import { type AccountGroupId } from '@metamask/account-api'; +import type { AccountGroupId } from '@metamask/account-api'; import type { AccountWalletType, AccountWalletId, diff --git a/packages/account-tree-controller/tests/mockMessenger.ts b/packages/account-tree-controller/tests/mockMessenger.ts index 10fa770a675..80df082b9c5 100644 --- a/packages/account-tree-controller/tests/mockMessenger.ts +++ b/packages/account-tree-controller/tests/mockMessenger.ts @@ -1,9 +1,8 @@ -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MockAnyNamespace, - type MessengerActions, - type MessengerEvents, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, } from '@metamask/messenger'; import type { AccountTreeControllerMessenger } from '../src/types'; diff --git a/packages/accounts-controller/CHANGELOG.md b/packages/accounts-controller/CHANGELOG.md index fa1e3132060..8ba444bbe2b 100644 --- a/packages/accounts-controller/CHANGELOG.md +++ b/packages/accounts-controller/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7258](https://github.com/MetaMask/core/pull/7258)) + - The dependencies moved are: + - `@metamask/keyring-controller` (^25.0.0) + - `@metamask/network-controller` (^27.0.0) + - `@metamask/snaps-controllers` (^14.0.1) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [35.0.0] ### Changed diff --git a/packages/accounts-controller/package.json b/packages/accounts-controller/package.json index dde00cdb34b..8403dab534e 100644 --- a/packages/accounts-controller/package.json +++ b/packages/accounts-controller/package.json @@ -52,9 +52,12 @@ "@metamask/base-controller": "^9.0.0", "@metamask/eth-snap-keyring": "^18.0.0", "@metamask/keyring-api": "^21.0.0", + "@metamask/keyring-controller": "^25.0.0", "@metamask/keyring-internal-api": "^9.0.0", "@metamask/keyring-utils": "^3.1.0", "@metamask/messenger": "^0.3.0", + "@metamask/network-controller": "^27.0.0", + "@metamask/snaps-controllers": "^14.0.1", "@metamask/snaps-sdk": "^9.0.0", "@metamask/snaps-utils": "^11.0.0", "@metamask/superstruct": "^3.1.0", @@ -68,10 +71,7 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@metamask/controller-utils": "^11.16.0", - "@metamask/keyring-controller": "^25.0.0", - "@metamask/network-controller": "^26.0.0", "@metamask/providers": "^22.1.0", - "@metamask/snaps-controllers": "^14.0.1", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "@types/readable-stream": "^2.3.0", @@ -83,10 +83,7 @@ "webextension-polyfill": "^0.12.0" }, "peerDependencies": { - "@metamask/keyring-controller": "^25.0.0", - "@metamask/network-controller": "^26.0.0", "@metamask/providers": "^22.0.0", - "@metamask/snaps-controllers": "^14.0.0", "webextension-polyfill": "^0.10.0 || ^0.11.0 || ^0.12.0" }, "engines": { diff --git a/packages/accounts-controller/src/AccountsController.test.ts b/packages/accounts-controller/src/AccountsController.test.ts index d560b73f841..34de9f40bd2 100644 --- a/packages/accounts-controller/src/AccountsController.test.ts +++ b/packages/accounts-controller/src/AccountsController.test.ts @@ -15,12 +15,11 @@ import { } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { NetworkClientId } from '@metamask/network-controller'; import type { SnapControllerState } from '@metamask/snaps-controllers'; @@ -2386,7 +2385,7 @@ describe('AccountsController', () => { name: `${keyringTypeToName(keyringType)} 1`, id: 'mock-id', address: mockAddress1, - keyringType: keyringType as KeyringTypes, + keyringType, options: createMockInternalAccountOptions(0, keyringType, 0), }), ]; diff --git a/packages/accounts-controller/src/AccountsController.ts b/packages/accounts-controller/src/AccountsController.ts index 4526ec9e98b..dd0bd3c6a9e 100644 --- a/packages/accounts-controller/src/AccountsController.ts +++ b/packages/accounts-controller/src/AccountsController.ts @@ -1,13 +1,13 @@ -import { - type ControllerGetStateAction, - type ControllerStateChangeEvent, - BaseController, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; -import { - type SnapKeyringAccountAssetListUpdatedEvent, - type SnapKeyringAccountBalancesUpdatedEvent, - type SnapKeyringAccountTransactionsUpdatedEvent, - SnapKeyring, +import { SnapKeyring } from '@metamask/eth-snap-keyring'; +import type { + SnapKeyringAccountAssetListUpdatedEvent, + SnapKeyringAccountBalancesUpdatedEvent, + SnapKeyringAccountTransactionsUpdatedEvent, } from '@metamask/eth-snap-keyring'; import type { KeyringAccountEntropyOptions } from '@metamask/keyring-api'; import { @@ -17,13 +17,13 @@ import { isEvmAccountType, KeyringAccountEntropyTypeOption, } from '@metamask/keyring-api'; -import type { KeyringObject } from '@metamask/keyring-controller'; -import { - type KeyringControllerState, - type KeyringControllerGetKeyringsByTypeAction, - type KeyringControllerStateChangeEvent, - type KeyringControllerGetStateAction, - KeyringTypes, +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { + KeyringControllerState, + KeyringControllerGetKeyringsByTypeAction, + KeyringControllerStateChangeEvent, + KeyringControllerGetStateAction, + KeyringObject, } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { isScopeEqualToAny } from '@metamask/keyring-utils'; @@ -34,7 +34,8 @@ import type { SnapStateChange, } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; -import { type CaipChainId, isCaipChainId } from '@metamask/utils'; +import { isCaipChainId } from '@metamask/utils'; +import type { CaipChainId } from '@metamask/utils'; import type { WritableDraft } from 'immer/dist/internal.js'; import { cloneDeep } from 'lodash'; diff --git a/packages/address-book-controller/src/AddressBookController.test.ts b/packages/address-book-controller/src/AddressBookController.test.ts index 7c8f90a9fd5..675196e73f7 100644 --- a/packages/address-book-controller/src/AddressBookController.test.ts +++ b/packages/address-book-controller/src/AddressBookController.test.ts @@ -1,11 +1,10 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import { toHex } from '@metamask/controller-utils'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { Hex } from '@metamask/utils'; diff --git a/packages/address-book-controller/src/AddressBookController.ts b/packages/address-book-controller/src/AddressBookController.ts index fd26199b021..38793f866dd 100644 --- a/packages/address-book-controller/src/AddressBookController.ts +++ b/packages/address-book-controller/src/AddressBookController.ts @@ -249,8 +249,7 @@ export class AddressBookController extends BaseController< if ( ![chainId, address].every((key) => isSafeDynamicKey(key)) || !isValidHexAddress(address) || - !this.state.addressBook[chainId] || - !this.state.addressBook[chainId][address] + !this.state.addressBook[chainId]?.[address] ) { return false; } diff --git a/packages/analytics-controller/package.json b/packages/analytics-controller/package.json index b06516dd293..69bc572027f 100644 --- a/packages/analytics-controller/package.json +++ b/packages/analytics-controller/package.json @@ -14,7 +14,7 @@ "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "MIT", + "license": "(MIT OR Apache-2.0)", "sideEffects": false, "exports": { ".": { diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 131fe84af43..20ad4531da4 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -1,17 +1,13 @@ -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MockAnyNamespace, -} from '@metamask/messenger'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { MockAnyNamespace } from '@metamask/messenger'; import { validate as uuidValidate, version as uuidVersion } from 'uuid'; -import { - AnalyticsController, - type AnalyticsControllerMessenger, - type AnalyticsControllerActions, - type AnalyticsControllerEvents, - type AnalyticsPlatformAdapter, - getDefaultAnalyticsControllerState, +import { AnalyticsController, getDefaultAnalyticsControllerState } from '.'; +import type { + AnalyticsControllerMessenger, + AnalyticsControllerActions, + AnalyticsControllerEvents, + AnalyticsPlatformAdapter, } from '.'; import type { AnalyticsControllerState } from '.'; diff --git a/packages/announcement-controller/src/AnnouncementController.test.ts b/packages/announcement-controller/src/AnnouncementController.test.ts index ea2fa04dcd0..11de2cc2089 100644 --- a/packages/announcement-controller/src/AnnouncementController.test.ts +++ b/packages/announcement-controller/src/AnnouncementController.test.ts @@ -1,9 +1,6 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MockAnyNamespace, -} from '@metamask/messenger'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { MockAnyNamespace } from '@metamask/messenger'; import type { AnnouncementControllerState, diff --git a/packages/app-metadata-controller/src/AppMetadataController.test.ts b/packages/app-metadata-controller/src/AppMetadataController.test.ts index da8a8e87235..c2d77ee62a0 100644 --- a/packages/app-metadata-controller/src/AppMetadataController.test.ts +++ b/packages/app-metadata-controller/src/AppMetadataController.test.ts @@ -1,16 +1,15 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MockAnyNamespace, -} from '@metamask/messenger'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { MockAnyNamespace } from '@metamask/messenger'; import { AppMetadataController, getDefaultAppMetadataControllerState, - type AppMetadataControllerOptions, - type AppMetadataControllerActions, - type AppMetadataControllerEvents, +} from './AppMetadataController'; +import type { + AppMetadataControllerOptions, + AppMetadataControllerActions, + AppMetadataControllerEvents, } from './AppMetadataController'; describe('AppMetadataController', () => { diff --git a/packages/approval-controller/src/ApprovalController.test.ts b/packages/approval-controller/src/ApprovalController.test.ts index a84b05b218f..639063994c8 100644 --- a/packages/approval-controller/src/ApprovalController.test.ts +++ b/packages/approval-controller/src/ApprovalController.test.ts @@ -1,12 +1,11 @@ /* eslint-disable jest/expect-expect */ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { errorCodes, JsonRpcError } from '@metamask/rpc-errors'; import { nanoid } from 'nanoid'; diff --git a/packages/approval-controller/src/ApprovalController.ts b/packages/approval-controller/src/ApprovalController.ts index 582382cd9b7..ed92ddb451d 100644 --- a/packages/approval-controller/src/ApprovalController.ts +++ b/packages/approval-controller/src/ApprovalController.ts @@ -1,11 +1,9 @@ +import { BaseController } from '@metamask/base-controller'; import type { ControllerGetStateAction, StateMetadata, } from '@metamask/base-controller'; -import { - BaseController, - type ControllerStateChangeEvent, -} from '@metamask/base-controller'; +import type { ControllerStateChangeEvent } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import type { JsonRpcError, DataWithOptionalCause } from '@metamask/rpc-errors'; import { rpcErrors } from '@metamask/rpc-errors'; diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index e1b6f57ae53..bbabbcceb46 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Support for Monad in NFT assets-controllers, [#7254](https://github.com/MetaMask/core/pull/7254) + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.4.0` to `^62.5.0` ([#7325](https://github.com/MetaMask/core/pull/7325)) + +### Fixed + +- Added decimal precision (default 9dp) for `CurrencyRateController` `conversionRate` and `conversionRate` properties. ([#7324](https://github.com/MetaMask/core/pull/7324)) + - This fixes any BigNumber conversion errors due to exceeding the 15 significant digit limit + +## [93.1.0] + +### Added + +- Add multicall address for Chains: `MegaETH Testnet V2`, `MegaETH Mainnet` ([#7287](https://github.com/MetaMask/core/pull/7287)) + +### Fixed + +- Fix trending tokens API request to use correct `sort` query parameter instead of `sortBy` ([#7310](https://github.com/MetaMask/core/pull/7310)) + +## [93.0.0] + +### Added + +- **BREAKING:** `TokenBalancesController` now subscribes to `AccountsController:selectedEvmAccountChange` event to trigger immediate balance updates when users switch accounts ([#7279](https://github.com/MetaMask/core/pull/7279)) + +### Changed + +- Bump `@metamask/network-controller` from `^26.0.0` to `^27.0.0` ([#7258](https://github.com/MetaMask/core/pull/7258)) +- Bump `@metamask/transaction-controller` from `^62.3.0` to `^62.4.0` ([#7257](https://github.com/MetaMask/core/pull/7257), [#7289](https://github.com/MetaMask/core/pull/7289)) +- `AccountTrackerController` now normalizes addresses to lowercase internally before calling balance fetchers to match `TokenBalancesController` and enable HTTP request caching ([#7279](https://github.com/MetaMask/core/pull/7279)) + +### Fixed + +- Fix 2dp value in `CurrencyRateController`([#7276](https://github.com/MetaMask/core/pull/7276)) +- Fix token search API to use correct `networks` query parameter instead of `chainIds` ([#7261](https://github.com/MetaMask/core/pull/7261)) + +## [92.0.0] + +### Added + +- Support for optionally fetching market data when calling searchTokens ([#7226](https://github.com/MetaMask/core/pull/7226)) +- **BREAKING:** Add optional JWT token authentication to multi-chain accounts API calls ([#7165](https://github.com/MetaMask/core/pull/7165)) + - `fetchMultiChainBalances` and `fetchMultiChainBalancesV4` now accept an optional `jwtToken` parameter + - `TokenDetectionController` fetches and passes JWT token from `AuthenticationController` when using Accounts API + - `TokenBalancesController` fetches and passes JWT token through balance fetcher chain + - JWT token is included in `Authorization: Bearer ` header when provided + - Backward compatible: token parameter is optional and APIs work without authentication + +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7220](https://github.com/MetaMask/core/pull/7220), [#7236](https://github.com/MetaMask/core/pull/7236)) + - The dependencies moved are: + - `@metamask/account-tree-controller` (^4.0.0) + - `@metamask/accounts-controller` (^35.0.0) + - `@metamask/approval-controller` (^8.0.0) + - `@metamask/core-backend` (^5.0.0) + - `@metamask/keyring-controller` (^25.0.0) + - `@metamask/multichain-account-service` (^4.0.0) + - `@metamask/network-controller` (^26.0.0) + - `@metamask/permission-controller` (^12.1.1) + - `@metamask/phishing-controller` (^16.1.0) + - `@metamask/preferences-controller` (^22.0.0) + - `@metamask/profile-sync-controller` (^27.0.0) + - `@metamask/snaps-controllers` (^14.0.1) + - `@metamask/transaction-controller` (^62.3.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + +### Fixed + +- Fix `TokenBalancesController` state that store both lowercase and checksum account addresses ([#7217](https://github.com/MetaMask/core/pull/7217)) +- `TokenBalancesController`: state inconsistency by ensuring all account addresses are stored in lowercase format ([#7216](https://github.com/MetaMask/core/pull/7216)) +- Add MON (Monad) to supported currencies list in token prices service ([#7250](https://github.com/MetaMask/core/pull/7250)) + +## [91.0.0] + +### Changed + +- **BREAKING:** Update `spot-prices` endpoint to use Price API v3 ([#7119](https://github.com/MetaMask/core/pull/7119)) + - Update `AbstractTokenPricesService.fetchTokenPrices` arguments and return type + - Update `CodefiTokenPricesServiceV2` list of supported currencies + - Update `TokenRatesController` to fetch prices by native currency instead of by chain + - Remove legacy polling code and unused events from `TokenRatesController` + ## [90.0.0] ### Added @@ -2306,7 +2395,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use Ethers for AssetsContractController ([#845](https://github.com/MetaMask/core/pull/845)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@90.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@93.1.0...HEAD +[93.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@93.0.0...@metamask/assets-controllers@93.1.0 +[93.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@92.0.0...@metamask/assets-controllers@93.0.0 +[92.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@91.0.0...@metamask/assets-controllers@92.0.0 +[91.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@90.0.0...@metamask/assets-controllers@91.0.0 [90.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@89.0.1...@metamask/assets-controllers@90.0.0 [89.0.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@89.0.0...@metamask/assets-controllers@89.0.1 [89.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@88.0.0...@metamask/assets-controllers@89.0.0 diff --git a/packages/assets-controllers/jest.config.js b/packages/assets-controllers/jest.config.js index c7e580801e6..add4e35680f 100644 --- a/packages/assets-controllers/jest.config.js +++ b/packages/assets-controllers/jest.config.js @@ -24,7 +24,7 @@ module.exports = merge(baseConfig, { coverageThreshold: { global: { branches: 90.5, - functions: 99, + functions: 98, lines: 98, statements: 98, }, diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 713da73f50d..0f14de3a4a1 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controllers", - "version": "90.0.0", + "version": "93.1.0", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "MetaMask", @@ -55,17 +55,30 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/abi-utils": "^2.0.3", + "@metamask/account-tree-controller": "^4.0.0", + "@metamask/accounts-controller": "^35.0.0", + "@metamask/approval-controller": "^8.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/contract-metadata": "^2.4.0", "@metamask/controller-utils": "^11.16.0", + "@metamask/core-backend": "^5.0.0", "@metamask/eth-query": "^4.0.0", "@metamask/keyring-api": "^21.0.0", + "@metamask/keyring-controller": "^25.0.0", "@metamask/messenger": "^0.3.0", "@metamask/metamask-eth-abis": "^3.1.1", + "@metamask/multichain-account-service": "^4.0.0", + "@metamask/network-controller": "^27.0.0", + "@metamask/permission-controller": "^12.1.1", + "@metamask/phishing-controller": "^16.1.0", "@metamask/polling-controller": "^16.0.0", + "@metamask/preferences-controller": "^22.0.0", + "@metamask/profile-sync-controller": "^27.0.0", "@metamask/rpc-errors": "^7.0.2", + "@metamask/snaps-controllers": "^14.0.1", "@metamask/snaps-sdk": "^9.0.0", "@metamask/snaps-utils": "^11.0.0", + "@metamask/transaction-controller": "^62.5.0", "@metamask/utils": "^11.8.1", "@types/bn.js": "^5.1.5", "@types/uuid": "^8.3.0", @@ -82,23 +95,11 @@ "devDependencies": { "@babel/runtime": "^7.23.9", "@metamask/account-api": "^0.12.0", - "@metamask/account-tree-controller": "^4.0.0", - "@metamask/accounts-controller": "^35.0.0", - "@metamask/approval-controller": "^8.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/core-backend": "^5.0.0", "@metamask/ethjs-provider-http": "^0.3.0", - "@metamask/keyring-controller": "^25.0.0", "@metamask/keyring-internal-api": "^9.0.0", "@metamask/keyring-snap-client": "^8.0.0", - "@metamask/multichain-account-service": "^4.0.0", - "@metamask/network-controller": "^26.0.0", - "@metamask/permission-controller": "^12.1.1", - "@metamask/phishing-controller": "^16.0.0", - "@metamask/preferences-controller": "^22.0.0", "@metamask/providers": "^22.1.0", - "@metamask/snaps-controllers": "^14.0.1", - "@metamask/transaction-controller": "^62.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.191", @@ -115,18 +116,7 @@ "webextension-polyfill": "^0.12.0" }, "peerDependencies": { - "@metamask/account-tree-controller": "^4.0.0", - "@metamask/accounts-controller": "^35.0.0", - "@metamask/approval-controller": "^8.0.0", - "@metamask/core-backend": "^5.0.0", - "@metamask/keyring-controller": "^25.0.0", - "@metamask/network-controller": "^26.0.0", - "@metamask/permission-controller": "^12.0.0", - "@metamask/phishing-controller": "^16.0.0", - "@metamask/preferences-controller": "^22.0.0", "@metamask/providers": "^22.0.0", - "@metamask/snaps-controllers": "^14.0.0", - "@metamask/transaction-controller": "^62.0.0", "webextension-polyfill": "^0.10.0 || ^0.11.0 || ^0.12.0" }, "engines": { diff --git a/packages/assets-controllers/src/AccountTrackerController.test.ts b/packages/assets-controllers/src/AccountTrackerController.test.ts index a3a467c1cca..5bd141143e0 100644 --- a/packages/assets-controllers/src/AccountTrackerController.test.ts +++ b/packages/assets-controllers/src/AccountTrackerController.test.ts @@ -1,26 +1,24 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import { query, toChecksumHexAddress } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; -import type { NetworkConfiguration } from '@metamask/network-controller'; -import { - type NetworkClientId, - type NetworkClientConfiguration, - getDefaultNetworkControllerState, +import { getDefaultNetworkControllerState } from '@metamask/network-controller'; +import type { + NetworkClientId, + NetworkClientConfiguration, + NetworkConfiguration, } from '@metamask/network-controller'; import { getDefaultPreferencesState } from '@metamask/preferences-controller'; -import { - TransactionStatus, - type TransactionMeta, -} from '@metamask/transaction-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import BN from 'bn.js'; -import { useFakeTimers, type SinonFakeTimers } from 'sinon'; +import { useFakeTimers } from 'sinon'; +import type { SinonFakeTimers } from 'sinon'; import type { AccountTrackerControllerMessenger } from './AccountTrackerController'; import { AccountTrackerController } from './AccountTrackerController'; @@ -68,7 +66,7 @@ const mockGetStakedBalanceForChain = async (addresses: string[]) => const ADDRESS_1 = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; const CHECKSUM_ADDRESS_1 = toChecksumHexAddress(ADDRESS_1); const ACCOUNT_1 = createMockInternalAccount({ address: ADDRESS_1 }); -const ADDRESS_2 = '0x742d35Cc6634C0532925a3b844Bc454e4438f44e'; +const ADDRESS_2 = '0x742d35cc6634c0532925a3b844bc454e4438f44e'; // lowercase for consistent caching const CHECKSUM_ADDRESS_2 = toChecksumHexAddress(ADDRESS_2); const ACCOUNT_2 = createMockInternalAccount({ address: ADDRESS_2 }); const EMPTY_ACCOUNT = { @@ -250,7 +248,7 @@ describe('AccountTrackerController', () => { mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ tokenBalances: { '0x0000000000000000000000000000000000000000': { - [CHECKSUM_ADDRESS_1]: new BN('abcdef', 16), + [ADDRESS_1]: new BN('abcdef', 16), }, }, stakedBalances: {}, @@ -280,7 +278,7 @@ describe('AccountTrackerController', () => { mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ tokenBalances: { '0x0000000000000000000000000000000000000000': { - [CHECKSUM_ADDRESS_1]: new BN('abcdef', 16), + [ADDRESS_1]: new BN('abcdef', 16), }, }, stakedBalances: {}, @@ -403,7 +401,7 @@ describe('AccountTrackerController', () => { mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ tokenBalances: { '0x0000000000000000000000000000000000000000': { - [CHECKSUM_ADDRESS_1]: new BN('acac5457a3517e', 16), // checksum format when multi-account disabled + [ADDRESS_1]: new BN('acac5457a3517e', 16), // lowercase }, }, stakedBalances: {}, @@ -469,11 +467,11 @@ describe('AccountTrackerController', () => { mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ tokenBalances: { '0x0000000000000000000000000000000000000000': { - [CHECKSUM_ADDRESS_1]: new BN('acac5457a3517e', 16), + [ADDRESS_1]: new BN('acac5457a3517e', 16), }, }, stakedBalances: { - [CHECKSUM_ADDRESS_1]: new BN('1', 16), + [ADDRESS_1]: new BN('1', 16), }, }); @@ -509,11 +507,11 @@ describe('AccountTrackerController', () => { it('should not update staked balance when includeStakedAssets is disabled', async () => { // Mock for single address balance update (no staked balances) - // When multi-account is disabled, the fetcher requests checksum addresses + // Use lowercase addresses for consistent caching across controllers mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ tokenBalances: { '0x0000000000000000000000000000000000000000': { - [CHECKSUM_ADDRESS_1]: new BN('acac5457a3517e', 16), // checksum format when multi-account disabled + [ADDRESS_1]: new BN('acac5457a3517e', 16), // lowercase }, }, stakedBalances: {}, // No staked balances when includeStakedAssets is false @@ -757,7 +755,7 @@ describe('AccountTrackerController', () => { mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ tokenBalances: { '0x0000000000000000000000000000000000000000': { - [CHECKSUM_ADDRESS_1]: new BN('10', 16), // checksum format when multi-account disabled + [ADDRESS_1]: new BN('10', 16), // lowercase }, }, stakedBalances: {}, @@ -843,11 +841,11 @@ describe('AccountTrackerController', () => { mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ tokenBalances: { '0x0000000000000000000000000000000000000000': { - [CHECKSUM_ADDRESS_1]: new BN('acac5457a3517e', 16), + [ADDRESS_1]: new BN('acac5457a3517e', 16), }, }, stakedBalances: { - [CHECKSUM_ADDRESS_1]: new BN('1', 16), + [ADDRESS_1]: new BN('1', 16), }, }); @@ -890,11 +888,11 @@ describe('AccountTrackerController', () => { it('should not update staked balance when includeStakedAssets is disabled', async () => { // Mock for single address balance update (no staked balances) - // When multi-account is disabled, the fetcher requests checksum addresses + // Use lowercase addresses for consistent caching across controllers mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ tokenBalances: { '0x0000000000000000000000000000000000000000': { - [CHECKSUM_ADDRESS_1]: new BN('acac5457a3517e', 16), // checksum format when multi-account disabled + [ADDRESS_1]: new BN('acac5457a3517e', 16), // lowercase }, }, stakedBalances: {}, // No staked balances when includeStakedAssets is false diff --git a/packages/assets-controllers/src/AccountTrackerController.ts b/packages/assets-controllers/src/AccountTrackerController.ts index c61a6118567..84f42d04230 100644 --- a/packages/assets-controllers/src/AccountTrackerController.ts +++ b/packages/assets-controllers/src/AccountTrackerController.ts @@ -31,19 +31,20 @@ import type { TransactionControllerUnapprovedTransactionAddedEvent, TransactionMeta, } from '@metamask/transaction-controller'; -import { assert, type Hex } from '@metamask/utils'; +import { assert } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { Mutex } from 'async-mutex'; import { cloneDeep, isEqual } from 'lodash'; -import { - STAKING_CONTRACT_ADDRESS_BY_CHAINID, - type AssetsContractController, - type StakedBalance, +import { STAKING_CONTRACT_ADDRESS_BY_CHAINID } from './AssetsContractController'; +import type { + AssetsContractController, + StakedBalance, } from './AssetsContractController'; -import { - AccountsApiBalanceFetcher, - type BalanceFetcher, - type ProcessedBalance, +import { AccountsApiBalanceFetcher } from './multi-chain-accounts-service/api-balance-fetcher'; +import type { + BalanceFetcher, + ProcessedBalance, } from './multi-chain-accounts-service/api-balance-fetcher'; import { RpcBalanceFetcher } from './rpc-service/rpc-balance-fetcher'; @@ -625,6 +626,14 @@ export class AccountTrackerController extends StaticIntervalPollingController ({ + ...account, + address: account.address.toLowerCase(), + })); + // Try each fetcher in order, removing successfully processed chains for (const fetcher of this.#balanceFetchers) { const supportedChains = remainingChains.filter((c) => @@ -638,8 +647,8 @@ export class AccountTrackerController extends StaticIntervalPollingController 0) { diff --git a/packages/assets-controllers/src/AssetsContractController.test.ts b/packages/assets-controllers/src/AssetsContractController.test.ts index 8c7320d8ab3..31e120583a5 100644 --- a/packages/assets-controllers/src/AssetsContractController.test.ts +++ b/packages/assets-controllers/src/AssetsContractController.test.ts @@ -7,12 +7,11 @@ import { NetworkType, } from '@metamask/controller-utils'; import HttpProvider from '@metamask/ethjs-provider-http'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { Provider, diff --git a/packages/assets-controllers/src/AssetsContractController.ts b/packages/assets-controllers/src/AssetsContractController.ts index f6cf557fd67..f453cf870be 100644 --- a/packages/assets-controllers/src/AssetsContractController.ts +++ b/packages/assets-controllers/src/AssetsContractController.ts @@ -14,7 +14,8 @@ import type { Provider, } from '@metamask/network-controller'; import type { PreferencesControllerStateChangeEvent } from '@metamask/preferences-controller'; -import { getKnownPropertyNames, type Hex } from '@metamask/utils'; +import { getKnownPropertyNames } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import type BN from 'bn.js'; import abiSingleCallBalancesContract from 'single-call-balance-checker-abi'; diff --git a/packages/assets-controllers/src/CurrencyRateController.test.ts b/packages/assets-controllers/src/CurrencyRateController.test.ts index ed84bdd47dd..1a5cfea2e80 100644 --- a/packages/assets-controllers/src/CurrencyRateController.test.ts +++ b/packages/assets-controllers/src/CurrencyRateController.test.ts @@ -4,12 +4,11 @@ import { NetworkType, NetworksTicker, } from '@metamask/controller-utils'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { NetworkConfiguration } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; @@ -20,7 +19,7 @@ import { CurrencyRateController } from './CurrencyRateController'; import type { AbstractTokenPricesService } from './token-prices-service'; import { advanceTime } from '../../../tests/helpers'; -const namespace = 'CurrencyRateController' as const; +const namespace = 'CurrencyRateController'; type AllCurrencyRateControllerActions = MessengerActions; @@ -44,7 +43,7 @@ function buildMockTokenPricesService( ): AbstractTokenPricesService { return { async fetchTokenPrices() { - return {}; + return []; }, async fetchExchangeRates() { return {}; @@ -284,7 +283,7 @@ describe('CurrencyRateController', () => { expect(controller.state.currencyRates).toStrictEqual({ ETH: { conversionDate: 10, - conversionRate: 4149.76, + conversionRate: 4149.764437074, usdConversionRate: null, }, }); @@ -298,7 +297,7 @@ describe('CurrencyRateController', () => { expect(controller.state.currencyRates).toStrictEqual({ ETH: { conversionDate: 20, - conversionRate: 4149.76, + conversionRate: 4149.764437074, usdConversionRate: null, }, }); @@ -417,8 +416,8 @@ describe('CurrencyRateController', () => { expect(controller.state.currencyRates).toStrictEqual({ ETH: { conversionDate: getStubbedDate() / 1000, - conversionRate: 4149.76, - usdConversionRate: 0.01, + conversionRate: 4149.764437074, + usdConversionRate: 0.009009009, }, }); @@ -469,7 +468,7 @@ describe('CurrencyRateController', () => { }, SepoliaETH: { conversionDate: getStubbedDate() / 1000, - conversionRate: 4149.76, + conversionRate: 4149.764437074, usdConversionRate: 1000, }, }); @@ -538,13 +537,13 @@ describe('CurrencyRateController', () => { currencyRates: { ETH: { conversionDate: getStubbedDate() / 1000, - conversionRate: 4149.76, - usdConversionRate: 181.82, + conversionRate: 4149.764437074, + usdConversionRate: 181.818181818, }, BTC: { conversionDate: getStubbedDate() / 1000, - conversionRate: 9636.65, - usdConversionRate: 454.55, + conversionRate: 9636.6518, + usdConversionRate: 454.545454545, }, }, }); @@ -1138,12 +1137,14 @@ describe('CurrencyRateController', () => { // Mock fetchTokenPrices to return token prices jest .spyOn(tokenPricesService, 'fetchTokenPrices') - .mockImplementation(async ({ chainId }) => { - if (chainId === '0x1') { - return { - '0x0000000000000000000000000000000000000000': { + .mockImplementation(async ({ assets }) => { + if (assets.some((asset) => asset.chainId === '0x1')) { + return [ + { currency: 'usd', tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: assets[0].chainId, + assetId: 'xx:yy/aa:bb', price: 2500.5, pricePercentChange1d: 0, priceChange1d: 0, @@ -1163,13 +1164,16 @@ describe('CurrencyRateController', () => { pricePercentChange7d: 100, totalVolume: 100, }, - }; + ]; } - if (chainId === '0x89') { - return { - '0x0000000000000000000000000000000000001010': { + + if (assets.some((asset) => asset.chainId === '0x89')) { + return [ + { currency: 'usd', tokenAddress: '0x0000000000000000000000000000000000001010', + chainId: assets[0].chainId, + assetId: 'xx:yy/aa:bb', price: 0.85, pricePercentChange1d: 0, priceChange1d: 0, @@ -1189,9 +1193,9 @@ describe('CurrencyRateController', () => { pricePercentChange7d: 100, totalVolume: 100, }, - }; + ]; } - return {}; + return []; }); // Make crypto compare also fail by not mocking it (no nock setup) @@ -1255,12 +1259,19 @@ describe('CurrencyRateController', () => { const fetchTokenPricesSpy = jest .spyOn(tokenPricesService, 'fetchTokenPrices') - .mockImplementation(async ({ chainId }) => { - if (chainId === '0x1' || chainId === '0xaa36a7') { - return { - '0x0000000000000000000000000000000000000000': { + .mockImplementation(async ({ assets }) => { + if ( + assets.some( + (asset) => + asset.chainId === '0x1' || asset.chainId === '0xaa36a7', + ) + ) { + return [ + { currency: 'usd', tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: assets[0].chainId, + assetId: 'xx:yy/aa:bb', price: 2500.5, pricePercentChange1d: 0, priceChange1d: 0, @@ -1280,9 +1291,9 @@ describe('CurrencyRateController', () => { pricePercentChange7d: 100, totalVolume: 100, }, - }; + ]; } - return {}; + return []; }); const controller = new CurrencyRateController({ @@ -1296,8 +1307,12 @@ describe('CurrencyRateController', () => { // Should only call fetchTokenPrices once, using first matching chainId (line 255) expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(1); expect(fetchTokenPricesSpy).toHaveBeenCalledWith({ - chainId: '0x1', // First chainId with ETH as native currency - tokenAddresses: [], + assets: [ + { + chainId: '0x1', + tokenAddress: '0x0000000000000000000000000000000000000000', + }, + ], currency: 'usd', }); @@ -1338,13 +1353,15 @@ describe('CurrencyRateController', () => { jest .spyOn(tokenPricesService, 'fetchTokenPrices') - .mockImplementation(async ({ chainId }) => { - if (chainId === '0x1') { + .mockImplementation(async ({ assets }) => { + if (assets.some((asset) => asset.chainId === '0x1')) { // ETH succeeds - return { - '0x0000000000000000000000000000000000000000': { + return [ + { currency: 'usd', tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: assets[0].chainId, + assetId: 'xx:yy/aa:bb', price: 2500.5, pricePercentChange1d: 0, priceChange1d: 0, @@ -1364,7 +1381,7 @@ describe('CurrencyRateController', () => { pricePercentChange7d: 100, totalVolume: 100, }, - }; + ]; } // POL fails throw new Error('Failed to fetch POL price'); @@ -1427,7 +1444,7 @@ describe('CurrencyRateController', () => { .mockRejectedValue(new Error('Price API failed')); // Return empty object (no token price) - jest.spyOn(tokenPricesService, 'fetchTokenPrices').mockResolvedValue({}); + jest.spyOn(tokenPricesService, 'fetchTokenPrices').mockResolvedValue([]); const controller = new CurrencyRateController({ messenger, @@ -1475,12 +1492,14 @@ describe('CurrencyRateController', () => { const fetchTokenPricesSpy = jest .spyOn(tokenPricesService, 'fetchTokenPrices') - .mockImplementation(async ({ chainId }) => { - if (chainId === '0x1') { - return { - '0x0000000000000000000000000000000000000000': { + .mockImplementation(async ({ assets }) => { + if (assets.some((asset) => asset.chainId === '0x1')) { + return [ + { currency: 'usd', tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: assets[0].chainId, + assetId: 'xx:yy/aa:bb', price: 2500.5, pricePercentChange1d: 0, priceChange1d: 0, @@ -1500,9 +1519,9 @@ describe('CurrencyRateController', () => { pricePercentChange7d: 100, totalVolume: 100, }, - }; + ]; } - return {}; + return []; }); const controller = new CurrencyRateController({ @@ -1517,8 +1536,12 @@ describe('CurrencyRateController', () => { // Should only call fetchTokenPrices for ETH, not BNB (line 252: if chainIds.length > 0) expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(1); expect(fetchTokenPricesSpy).toHaveBeenCalledWith({ - chainId: '0x1', - tokenAddresses: [], + assets: [ + { + chainId: '0x1', + tokenAddress: '0x0000000000000000000000000000000000000000', + }, + ], currency: 'usd', }); @@ -1562,10 +1585,11 @@ describe('CurrencyRateController', () => { const fetchTokenPricesSpy = jest .spyOn(tokenPricesService, 'fetchTokenPrices') - .mockResolvedValue({ - '0x0000000000000000000000000000000000001010': { + .mockResolvedValue([ + { currency: 'usd', tokenAddress: '0x0000000000000000000000000000000000001010', + chainId: '0x89', price: 0.85, pricePercentChange1d: 0, priceChange1d: 0, @@ -1585,7 +1609,7 @@ describe('CurrencyRateController', () => { pricePercentChange7d: 100, totalVolume: 100, }, - }); + ]); const controller = new CurrencyRateController({ messenger, @@ -1597,8 +1621,12 @@ describe('CurrencyRateController', () => { // Should use Polygon's native token address (line 269) expect(fetchTokenPricesSpy).toHaveBeenCalledWith({ - chainId: '0x89', - tokenAddresses: [], + assets: [ + { + chainId: '0x89', + tokenAddress: '0x0000000000000000000000000000000000001010', + }, + ], currency: 'usd', }); diff --git a/packages/assets-controllers/src/CurrencyRateController.ts b/packages/assets-controllers/src/CurrencyRateController.ts index ef8a43d9598..feeb95ced2c 100644 --- a/packages/assets-controllers/src/CurrencyRateController.ts +++ b/packages/assets-controllers/src/CurrencyRateController.ts @@ -100,6 +100,9 @@ type CurrencyRatePollingInput = { nativeCurrencies: string[]; }; +const boundedPrecisionNumber = (value: number, precision = 9): number => + Number(value.toFixed(precision)); + /** * Controller that passively polls on a set interval for an exchange rate from the current network * asset to the user's preferred currency. @@ -193,24 +196,23 @@ export class CurrencyRateController extends StaticIntervalPollingController { - const rate = - priceApiExchangeRatesResponse[fetchedCurrency.toLowerCase()]; - - acc[nativeCurrency] = { - conversionDate: rate !== undefined ? Date.now() / 1000 : null, - conversionRate: rate?.value - ? Number((1 / rate?.value).toFixed(2)) - : null, - usdConversionRate: rate?.usd - ? Number((1 / rate?.usd).toFixed(2)) - : null, - }; - return acc; - }, - {} as CurrencyRateState['currencyRates'], - ); + const ratesPriceApi = Object.entries(nativeCurrenciesToFetch).reduce< + CurrencyRateState['currencyRates'] + >((acc, [nativeCurrency, fetchedCurrency]) => { + const rate = + priceApiExchangeRatesResponse[fetchedCurrency.toLowerCase()]; + + acc[nativeCurrency] = { + conversionDate: rate !== undefined ? Date.now() / 1000 : null, + conversionRate: rate?.value + ? boundedPrecisionNumber(1 / rate.value) + : null, + usdConversionRate: rate?.usd + ? boundedPrecisionNumber(1 / rate.usd) + : null, + }; + return acc; + }, {}); return ratesPriceApi; } catch (error) { console.error('Failed to fetch exchange rates.', error); @@ -226,31 +228,27 @@ export class CurrencyRateController extends StaticIntervalPollingController chainId(s) - const currencyToChainIds = Object.entries(nativeCurrenciesToFetch).reduce( - (acc, [nativeCurrency, fetchedCurrency]) => { - // Find the first chainId that has this native currency - const matchingEntry = ( - Object.entries(networkConfigurations) as [ - Hex, - NetworkConfiguration, - ][] - ).find( - ([, config]) => - config.nativeCurrency.toUpperCase() === - fetchedCurrency.toUpperCase(), - ); + const currencyToChainIds = Object.entries(nativeCurrenciesToFetch).reduce< + Record + >((acc, [nativeCurrency, fetchedCurrency]) => { + // Find the first chainId that has this native currency + const matchingEntry = ( + Object.entries(networkConfigurations) as [Hex, NetworkConfiguration][] + ).find( + ([, config]) => + config.nativeCurrency.toUpperCase() === + fetchedCurrency.toUpperCase(), + ); - if (matchingEntry) { - acc[nativeCurrency] = { - fetchedCurrency, - chainId: matchingEntry[0], - }; - } + if (matchingEntry) { + acc[nativeCurrency] = { + fetchedCurrency, + chainId: matchingEntry[0], + }; + } - return acc; - }, - {} as Record, - ); + return acc; + }, {}); // Step 3: Fetch token prices for each chainId const currencyToChainIdsEntries = Object.entries(currencyToChainIds); @@ -259,17 +257,22 @@ export class CurrencyRateController extends StaticIntervalPollingController + item.tokenAddress.toLowerCase() === + nativeTokenAddress.toLowerCase(), + ); return { nativeCurrency, conversionDate: tokenPrice ? Date.now() / 1000 : null, - conversionRate: tokenPrice?.price ?? null, + conversionRate: tokenPrice?.price + ? boundedPrecisionNumber(tokenPrice.price) + : null, usdConversionRate: null, // Token prices service doesn't provide USD rate in this context }; }), @@ -292,17 +295,20 @@ export class CurrencyRateController extends StaticIntervalPollingController { - acc[rate.nativeCurrency] = { - conversionDate: rate.conversionDate, - conversionRate: rate.conversionRate, - usdConversionRate: rate.usdConversionRate, - }; - return acc; - }, - {} as CurrencyRateState['currencyRates'], - ); + const ratesFromTokenPricesService = ratesFromTokenPrices.reduce< + CurrencyRateState['currencyRates'] + >((acc, rate) => { + acc[rate.nativeCurrency] = { + conversionDate: rate.conversionDate, + conversionRate: rate.conversionRate + ? boundedPrecisionNumber(rate.conversionRate) + : null, + usdConversionRate: rate.usdConversionRate + ? boundedPrecisionNumber(rate.usdConversionRate) + : null, + }; + return acc; + }, {}); return ratesFromTokenPricesService; } catch (error) { @@ -311,17 +317,16 @@ export class CurrencyRateController extends StaticIntervalPollingController { - acc[nativeCurrency] = { - conversionDate: null, - conversionRate: null, - usdConversionRate: null, - }; - return acc; - }, - {} as CurrencyRateState['currencyRates'], - ); + return Object.keys(nativeCurrenciesToFetch).reduce< + CurrencyRateState['currencyRates'] + >((acc, nativeCurrency) => { + acc[nativeCurrency] = { + conversionDate: null, + conversionRate: null, + usdConversionRate: null, + }; + return acc; + }, {}); } } @@ -342,19 +347,18 @@ export class CurrencyRateController extends StaticIntervalPollingController { - if (!nativeCurrency) { - return acc; - } - - acc[nativeCurrency] = testnetSymbols.includes(nativeCurrency) - ? FALL_BACK_VS_CURRENCY - : nativeCurrency; + const nativeCurrenciesToFetch = nativeCurrencies.reduce< + Record + >((acc, nativeCurrency) => { + if (!nativeCurrency) { return acc; - }, - {} as Record, - ); + } + + acc[nativeCurrency] = testnetSymbols.includes(nativeCurrency) + ? FALL_BACK_VS_CURRENCY + : nativeCurrency; + return acc; + }, {}); const rates = await this.#fetchExchangeRatesWithFallback( nativeCurrenciesToFetch, diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts index 7d7c8d9101a..d3df3f8925f 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts @@ -1,11 +1,10 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import { BtcAccountType, EthAccountType } from '@metamask/keyring-api'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import * as calculateDefiMetrics from './calculate-defi-metrics'; diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts index b317414ee4f..960a39a4d25 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts @@ -18,10 +18,8 @@ import type { Hex } from '@metamask/utils'; import { calculateDeFiPositionMetrics } from './calculate-defi-metrics'; import type { DefiPositionResponse } from './fetch-positions'; import { buildPositionFetcher } from './fetch-positions'; -import { - groupDeFiPositions, - type GroupedDeFiPositions, -} from './group-defi-positions'; +import { groupDeFiPositions } from './group-defi-positions'; +import type { GroupedDeFiPositions } from './group-defi-positions'; const TEN_MINUTES_IN_MS = 600_000; diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.ts index 829efe71f1d..16b13b45396 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.ts @@ -143,12 +143,12 @@ function processToken( return tokenWithoutUnderlyings; }); - const marketValue = processedTokens.reduce( + const marketValue = processedTokens.reduce( (acc, t) => acc === undefined || t.marketValue === undefined ? undefined : acc + t.marketValue, - 0 as number | undefined, + 0, ); return { diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts index 15d3a24f159..7ead75e7e8d 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts @@ -12,12 +12,11 @@ import { } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { PermissionConstraint } from '@metamask/permission-controller'; import type { SubjectPermissions } from '@metamask/permission-controller'; diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts index a6638ab2a5c..e7e2e30c142 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts @@ -4,11 +4,11 @@ import type { AccountsControllerAccountRemovedEvent, AccountsControllerListMultichainAccountsAction, } from '@metamask/accounts-controller'; -import { - BaseController, - type ControllerGetStateAction, - type ControllerStateChangeEvent, - type StateMetadata, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, } from '@metamask/base-controller'; import { isEvmAccountType } from '@metamask/keyring-api'; import type { @@ -30,11 +30,8 @@ import type { } from '@metamask/snaps-controllers'; import type { FungibleAssetMetadata, Snap, SnapId } from '@metamask/snaps-sdk'; import { HandlerType } from '@metamask/snaps-utils'; -import { - isCaipAssetType, - parseCaipAssetType, - type CaipChainId, -} from '@metamask/utils'; +import { isCaipAssetType, parseCaipAssetType } from '@metamask/utils'; +import type { CaipChainId } from '@metamask/utils'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import type { MutexInterface } from 'async-mutex'; import { Mutex } from 'async-mutex'; diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts index f332a0f0d42..b73b1b83e0d 100644 --- a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts @@ -5,19 +5,18 @@ import { SolAccountType } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { KeyringClient } from '@metamask/keyring-snap-client'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { OnAssetHistoricalPriceResponse } from '@metamask/snaps-sdk'; import { useFakeTimers } from 'sinon'; import { v4 as uuidv4 } from 'uuid'; import { MultichainAssetsRatesController } from '.'; -import { type MultichainAssetsRatesControllerMessenger } from './MultichainAssetsRatesController'; +import type { MultichainAssetsRatesControllerMessenger } from './MultichainAssetsRatesController'; import { advanceTime } from '../../../../tests/helpers'; type AllMultichainAssetsRateControllerActions = diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts index 345cee4a221..eec4daccb7b 100644 --- a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts @@ -8,7 +8,8 @@ import type { ControllerGetStateAction, StateMetadata, } from '@metamask/base-controller'; -import { type CaipAssetType, isEvmAccountType } from '@metamask/keyring-api'; +import { isEvmAccountType } from '@metamask/keyring-api'; +import type { CaipAssetType } from '@metamask/keyring-api'; import type { KeyringControllerLockEvent, KeyringControllerUnlockEvent, diff --git a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts index 9315586c59f..6fa56975632 100644 --- a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts +++ b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts @@ -13,12 +13,11 @@ import { } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { v4 as uuidv4 } from 'uuid'; diff --git a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts index 66d0b2be8ef..5e8e4129724 100644 --- a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts +++ b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts @@ -4,11 +4,11 @@ import type { AccountsControllerListMultichainAccountsAction, AccountsControllerAccountBalancesUpdatesEvent, } from '@metamask/accounts-controller'; -import { - BaseController, - type StateMetadata, - type ControllerGetStateAction, - type ControllerStateChangeEvent, +import { BaseController } from '@metamask/base-controller'; +import type { + StateMetadata, + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; import { isEvmAccountType } from '@metamask/keyring-api'; import type { diff --git a/packages/assets-controllers/src/NftController.test.ts b/packages/assets-controllers/src/NftController.test.ts index 78b78a41f4e..f6fda170b14 100644 --- a/packages/assets-controllers/src/NftController.test.ts +++ b/packages/assets-controllers/src/NftController.test.ts @@ -22,23 +22,20 @@ import { convertHexToDecimal, } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; -import { - type NetworkClientConfiguration, - type NetworkClientId, +import type { + NetworkClientConfiguration, + NetworkClientId, } from '@metamask/network-controller'; import type { BulkPhishingDetectionScanResponse } from '@metamask/phishing-controller'; import { RecommendedAction } from '@metamask/phishing-controller'; -import { - getDefaultPreferencesState, - type PreferencesState, -} from '@metamask/preferences-controller'; +import { getDefaultPreferencesState } from '@metamask/preferences-controller'; +import type { PreferencesState } from '@metamask/preferences-controller'; import type { Hex } from '@metamask/utils'; import BN from 'bn.js'; import nock from 'nock'; diff --git a/packages/assets-controllers/src/NftController.ts b/packages/assets-controllers/src/NftController.ts index 15113baa394..eee983ad246 100644 --- a/packages/assets-controllers/src/NftController.ts +++ b/packages/assets-controllers/src/NftController.ts @@ -5,11 +5,11 @@ import type { AccountsControllerGetSelectedAccountAction, } from '@metamask/accounts-controller'; import type { AddApprovalRequest } from '@metamask/approval-controller'; -import { - BaseController, - type ControllerStateChangeEvent, - type ControllerGetStateAction, - type StateMetadata, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerStateChangeEvent, + ControllerGetStateAction, + StateMetadata, } from '@metamask/base-controller'; import { safelyExecute, @@ -26,7 +26,7 @@ import { convertHexToDecimal, toHex, } from '@metamask/controller-utils'; -import { type InternalAccount } from '@metamask/keyring-internal-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { Messenger } from '@metamask/messenger'; import type { NetworkClientId, @@ -1052,7 +1052,7 @@ export class NftController extends BaseController< configuration: { chainId }, } = this.messenger.call( 'NetworkController:getNetworkClientById', - networkClientId as NetworkClientId, + networkClientId, ); const nftContracts = allNftContracts[userAddress]?.[chainId] || []; @@ -1676,7 +1676,7 @@ export class NftController extends BaseController< configuration: { chainId }, } = this.messenger.call( 'NetworkController:getNetworkClientById', - networkClientId as NetworkClientId, + networkClientId, ); const checksumHexAddress = toChecksumHexAddress(address); @@ -1718,7 +1718,7 @@ export class NftController extends BaseController< configuration: { chainId }, } = this.messenger.call( 'NetworkController:getNetworkClientById', - networkClientId as NetworkClientId, + networkClientId, ); const checksumHexAddress = toChecksumHexAddress(address); this.#removeAndIgnoreIndividualNft(checksumHexAddress, tokenId, { @@ -1769,7 +1769,7 @@ export class NftController extends BaseController< configuration: { chainId }, } = this.messenger.call( 'NetworkController:getNetworkClientById', - networkClientId as NetworkClientId, + networkClientId, ); const { address, tokenId } = nft; let isOwned = nft.isCurrentlyOwned; @@ -1845,7 +1845,7 @@ export class NftController extends BaseController< configuration: { chainId }, } = this.messenger.call( 'NetworkController:getNetworkClientById', - networkClientId as NetworkClientId, + networkClientId, ); const { allNfts } = this.state; const nfts = allNfts[addressToSearch]?.[chainId] || []; @@ -1896,7 +1896,7 @@ export class NftController extends BaseController< configuration: { chainId }, } = this.messenger.call( 'NetworkController:getNetworkClientById', - networkClientId as NetworkClientId, + networkClientId, ); const { allNfts } = this.state; const nfts = [...(allNfts[addressToSearch]?.[chainId] || [])]; diff --git a/packages/assets-controllers/src/NftDetectionController.test.ts b/packages/assets-controllers/src/NftDetectionController.test.ts index d485a77361f..0a599435945 100644 --- a/packages/assets-controllers/src/NftDetectionController.test.ts +++ b/packages/assets-controllers/src/NftDetectionController.test.ts @@ -4,12 +4,11 @@ import { ChainId, InfuraNetworkType, } from '@metamask/controller-utils'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { getDefaultNetworkControllerState, @@ -22,10 +21,8 @@ import type { NetworkController, NetworkState, } from '@metamask/network-controller'; -import { - getDefaultPreferencesState, - type PreferencesState, -} from '@metamask/preferences-controller'; +import { getDefaultPreferencesState } from '@metamask/preferences-controller'; +import type { PreferencesState } from '@metamask/preferences-controller'; import nock from 'nock'; import * as sinon from 'sinon'; @@ -34,8 +31,8 @@ import { getDefaultNftControllerState } from './NftController'; import { NftDetectionController, BlockaidResultType, - type NftDetectionControllerMessenger, } from './NftDetectionController'; +import type { NftDetectionControllerMessenger } from './NftDetectionController'; import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; import { FakeProvider } from '../../../tests/fake-provider'; import { advanceTime } from '../../../tests/helpers'; diff --git a/packages/assets-controllers/src/NftDetectionController.ts b/packages/assets-controllers/src/NftDetectionController.ts index cff23beeb25..0b06d81acd4 100644 --- a/packages/assets-controllers/src/NftDetectionController.ts +++ b/packages/assets-controllers/src/NftDetectionController.ts @@ -1,9 +1,9 @@ import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; import type { AddApprovalRequest } from '@metamask/approval-controller'; -import { - BaseController, - type ControllerGetStateAction, - type ControllerStateChangeEvent, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; import { toChecksumHexAddress, @@ -26,13 +26,14 @@ import type { PreferencesControllerStateChangeEvent, PreferencesState, } from '@metamask/preferences-controller'; -import { createDeferredPromise, type Hex } from '@metamask/utils'; +import { createDeferredPromise } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { Source } from './constants'; -import { - type NftController, - type NftControllerState, - type NftMetadata, +import type { + NftController, + NftControllerState, + NftMetadata, } from './NftController'; import type { NetworkControllerFindNetworkClientIdByChainIdAction } from '../../network-controller/src/NetworkController'; @@ -72,6 +73,7 @@ const supportedNftDetectionNetworks: Set = new Set([ '0x1', // Mainnet '0xe708', // Linea Mainnet '0x531', // Sei + '0x8f', // Monad ]); /** diff --git a/packages/assets-controllers/src/RatesController/RatesController.test.ts b/packages/assets-controllers/src/RatesController/RatesController.test.ts index ecb36af1e6c..4bd60c28af5 100644 --- a/packages/assets-controllers/src/RatesController/RatesController.test.ts +++ b/packages/assets-controllers/src/RatesController/RatesController.test.ts @@ -1,10 +1,9 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { useFakeTimers } from 'sinon'; diff --git a/packages/assets-controllers/src/RatesController/RatesController.ts b/packages/assets-controllers/src/RatesController/RatesController.ts index c7056cbe37a..b6ee0472cca 100644 --- a/packages/assets-controllers/src/RatesController/RatesController.ts +++ b/packages/assets-controllers/src/RatesController/RatesController.ts @@ -1,4 +1,5 @@ -import { BaseController, type StateMetadata } from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { StateMetadata } from '@metamask/base-controller'; import { Mutex } from 'async-mutex'; import type { Draft } from 'immer'; diff --git a/packages/assets-controllers/src/TokenBalancesController.test.ts b/packages/assets-controllers/src/TokenBalancesController.test.ts index 085b356ae43..45ad3a0443d 100644 --- a/packages/assets-controllers/src/TokenBalancesController.test.ts +++ b/packages/assets-controllers/src/TokenBalancesController.test.ts @@ -1,19 +1,20 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { toHex } from '@metamask/controller-utils'; +import { toChecksumHexAddress, toHex } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { NetworkState } from '@metamask/network-controller'; import type { PreferencesState } from '@metamask/preferences-controller'; import { CHAIN_IDS } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; import BN from 'bn.js'; import { useFakeTimers } from 'sinon'; +import { mockAPI_accountsAPI_MultichainAccountBalances } from './__fixtures__/account-api-v4-mocks'; import * as multicall from './multicall'; import { RpcBalanceFetcher } from './rpc-service/rpc-balance-fetcher'; import type { @@ -95,6 +96,7 @@ const setupController = ({ 'AccountTrackerController:getState', 'AccountTrackerController:updateNativeBalances', 'AccountTrackerController:updateStakedBalances', + 'AuthenticationController:getBearerToken', ], events: [ 'NetworkController:stateChange', @@ -103,6 +105,7 @@ const setupController = ({ 'KeyringController:accountRemoved', 'AccountActivityService:balanceUpdated', 'AccountActivityService:statusChanged', + 'AccountsController:selectedEvmAccountChange', ], }); @@ -314,6 +317,179 @@ describe('TokenBalancesController', () => { expect(controller.state).toStrictEqual({ tokenBalances: {} }); }); + describe('account address normalization', () => { + it('should normalize mixed-case account addresses to lowercase on initialization', () => { + const account = '0x393a8d3f7710047324d369a7cb368c0570c335b8'; + const checksummedAccount = '0x393A8D3f7710047324D369a7cB368C0570C335b8'; + const usdcToken = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const usdtToken = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; + const daiToken = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + + // Create state with duplicate accounts - one lowercase, one checksummed + const initialState: TokenBalancesControllerState = { + tokenBalances: { + [account as ChecksumAddress]: { + '0x1': { + [usdcToken]: '0x100', + [usdtToken]: '0x200', + }, + }, + [checksummedAccount as ChecksumAddress]: { + '0x1': { + [daiToken]: '0x300', + }, + '0x89': { + [usdtToken]: '0x400', + }, + }, + }, + }; + + const { controller } = setupController({ + config: { state: initialState }, + }); + + // After normalization, should only have lowercase account + const state = controller.state.tokenBalances; + const lowercaseAccount = account.toLowerCase() as ChecksumAddress; + + // Should have only one account (lowercase) + expect(Object.keys(state)).toHaveLength(1); + expect(state[lowercaseAccount]).toBeDefined(); + expect(state[checksummedAccount as ChecksumAddress]).toBeUndefined(); + + // Should merge balances from both versions + expect(state[lowercaseAccount]['0x1'][usdcToken]).toBe('0x100'); // From lowercase + expect(state[lowercaseAccount]['0x1'][usdtToken]).toBe('0x200'); // From lowercase + expect(state[lowercaseAccount]['0x1'][daiToken]).toBe('0x300'); // From checksummed + expect(state[lowercaseAccount]['0x89'][usdtToken]).toBe('0x400'); // From checksummed + + // Should have all tokens from both versions + expect(Object.keys(state[lowercaseAccount]['0x1'])).toHaveLength(3); + }); + + it('should not update state if all accounts are already lowercase', () => { + const account = '0x393a8d3f7710047324d369a7cb368c0570c335b8'; + const token = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + + const initialState: TokenBalancesControllerState = { + tokenBalances: { + [account as ChecksumAddress]: { + '0x1': { + [token]: '0x100', + }, + }, + }, + }; + + const { controller } = setupController({ + config: { state: initialState }, + }); + + expect(controller.state.tokenBalances).toStrictEqual( + initialState.tokenBalances, + ); + expect(Object.keys(controller.state.tokenBalances)).toHaveLength(1); + expect(Object.keys(controller.state.tokenBalances)[0]).toBe(account); + expect( + Object.keys(controller.state.tokenBalances).every( + (addr) => addr === addr.toLowerCase(), + ), + ).toBe(true); + }); + + it('should handle empty state without errors', () => { + const initialState: TokenBalancesControllerState = { + tokenBalances: {}, + }; + + expect(() => { + setupController({ + config: { state: initialState }, + }); + }).not.toThrow(); + }); + + it('should handle multiple different accounts with mixed casing', () => { + const account1 = '0x393a8d3f7710047324d369a7cb368c0570c335b8'; + const account1Checksum = '0x393A8D3f7710047324D369a7cB368C0570C335b8'; + const account2 = '0x372effc9bd72a008ce4601f4446dad715e455f97'; + const account2Checksum = '0x372EffC9BD72A008Ce4601F4446DAD715e455F97'; + const usdcToken = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const daiToken = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + + const initialState: TokenBalancesControllerState = { + tokenBalances: { + [account1 as ChecksumAddress]: { + '0x1': { [usdcToken]: '0x100' }, + }, + [account1Checksum as ChecksumAddress]: { + '0x89': { [usdcToken]: '0x200' }, + }, + [account2 as ChecksumAddress]: { + '0x1': { [usdcToken]: '0x300' }, + }, + [account2Checksum as ChecksumAddress]: { + '0x1': { [daiToken]: '0x400' }, // Different token to avoid conflict + }, + }, + }; + + const { controller } = setupController({ + config: { state: initialState }, + }); + + const state = controller.state.tokenBalances; + + // Should have exactly 2 accounts (both lowercase) + expect(Object.keys(state)).toHaveLength(2); + expect(state[account1.toLowerCase() as ChecksumAddress]).toBeDefined(); + expect(state[account2.toLowerCase() as ChecksumAddress]).toBeDefined(); + + expect( + state[account1.toLowerCase() as ChecksumAddress]['0x1'][usdcToken], + ).toBe('0x100'); + expect( + state[account1.toLowerCase() as ChecksumAddress]['0x89'][usdcToken], + ).toBe('0x200'); + + // Check merged balances for account2 (both tokens should exist) + expect( + state[account2.toLowerCase() as ChecksumAddress]['0x1'][usdcToken], + ).toBe('0x300'); + expect( + state[account2.toLowerCase() as ChecksumAddress]['0x1'][daiToken], + ).toBe('0x400'); + }); + + it('should preserve token addresses in checksum format while normalizing account addresses', () => { + const account = '0x393A8D3f7710047324D369a7cB368C0570C335b8'; + const token = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + + const initialState: TokenBalancesControllerState = { + tokenBalances: { + [account as ChecksumAddress]: { + '0x1': { + [token]: '0x100', + }, + }, + }, + }; + + const { controller } = setupController({ + config: { state: initialState }, + }); + + const state = controller.state.tokenBalances; + const lowercaseAccount = account.toLowerCase() as ChecksumAddress; + + // Token address should remain as-is (checksummed) + expect(state[lowercaseAccount]['0x1'][token]).toBe('0x100'); + // Check that the exact token address key exists + expect(Object.keys(state[lowercaseAccount]['0x1'])).toContain(token); + }); + }); + it('should poll and update balances in the right interval', async () => { const pollSpy = jest.spyOn( TokenBalancesController.prototype, @@ -1405,6 +1581,82 @@ describe('TokenBalancesController', () => { }); }); + describe('when selectedEvmAccountChange is published', () => { + it('calls updateBalances when account changes and tokens exist', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000001'; + const tokenAddress = '0x0000000000000000000000000000000000000010'; + const account = createMockInternalAccount({ + address: accountAddress, + }); + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'TEST', decimals: 18 }, + ], + }, + }, + }; + + const updateBalancesSpy = jest + .spyOn(TokenBalancesController.prototype, 'updateBalances') + .mockResolvedValue(undefined); + + const { messenger } = setupController({ + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + tokens, + listAccounts: [account], + }); + + // Publish account change event + messenger.publish('AccountsController:selectedEvmAccountChange', account); + + // Verify updateBalances was called with correct chainIds + expect(updateBalancesSpy).toHaveBeenCalledWith({ + chainIds: [chainId], + }); + + updateBalancesSpy.mockRestore(); + }); + + it('does not call updateBalances when no tokens exist', async () => { + const account = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + const updateBalancesSpy = jest.spyOn( + TokenBalancesController.prototype, + 'updateBalances', + ); + + const { messenger } = setupController({ + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + tokens: { + allTokens: {}, + allDetectedTokens: {}, + }, + listAccounts: [account], + }); + + // Publish account change event + messenger.publish('AccountsController:selectedEvmAccountChange', account); + + // Should not call updateBalances when there are no chains with tokens + expect(updateBalancesSpy).not.toHaveBeenCalled(); + + updateBalancesSpy.mockRestore(); + }); + }); + describe('multicall integration', () => { it('should use getTokenBalancesForMultipleAddresses when available', async () => { const mockGetTokenBalances = jest @@ -4277,6 +4529,7 @@ describe('TokenBalancesController', () => { ok: true, json: () => Promise.resolve([]), }); + const originalFetch = global.fetch; global.fetch = mockGlobalFetch; // Create controller with accountsApiChainIds to enable AccountsApi fetcher @@ -4311,8 +4564,7 @@ describe('TokenBalancesController', () => { supportsSpy.mockRestore(); fetchSpy.mockRestore(); mockedSafelyExecuteWithTimeout.mockRestore(); - // @ts-expect-error - deleting global fetch for test cleanup - delete global.fetch; + global.fetch = originalFetch; }); }); @@ -5215,6 +5467,48 @@ describe('TokenBalancesController', () => { }); }); + describe('TokenBalancesController - AccountsAPI integration', () => { + const accountAddress = '0x393a8d3f7710047324d369a7cb368c0570c335b8'; + const checksumAccountAddress = toChecksumHexAddress(accountAddress) as Hex; + const chainId = '0x89'; + + const arrange = () => { + const mockAccountsAPI = + mockAPI_accountsAPI_MultichainAccountBalances(accountAddress); + + const account = createMockInternalAccount({ address: accountAddress }); + + const { controller } = setupController({ + config: { + accountsApiChainIds: () => [chainId], // Enable Accounts API for this chain + allowExternalServices: () => true, + }, + listAccounts: [account], + }); + + return { + mockAccountsAPI, + controller, + }; + }; + + it('calls Accounts API and stores data with lowercased account address', async () => { + const { mockAccountsAPI, controller } = arrange(); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + expect(controller.state.tokenBalances[accountAddress]).toBeDefined(); + expect( + controller.state.tokenBalances[checksumAccountAddress], + ).toBeUndefined(); + + expect(mockAccountsAPI.isDone()).toBe(true); + }); + }); + describe('metadata', () => { it('includes expected state in debug snapshots', () => { const { controller } = setupController(); diff --git a/packages/assets-controllers/src/TokenBalancesController.ts b/packages/assets-controllers/src/TokenBalancesController.ts index f8bfed95b13..57736c12d9e 100644 --- a/packages/assets-controllers/src/TokenBalancesController.ts +++ b/packages/assets-controllers/src/TokenBalancesController.ts @@ -2,6 +2,7 @@ import { Web3Provider } from '@ethersproject/providers'; import type { AccountsControllerGetSelectedAccountAction, AccountsControllerListAccountsAction, + AccountsControllerSelectedEvmAccountChangeEvent, } from '@metamask/accounts-controller'; import type { ControllerGetStateAction, @@ -11,6 +12,7 @@ import type { import { BNToHex, isValidHexAddress, + safelyExecuteWithTimeout, toChecksumHexAddress, toHex, } from '@metamask/controller-utils'; @@ -32,6 +34,7 @@ import type { PreferencesControllerGetStateAction, PreferencesControllerStateChangeEvent, } from '@metamask/preferences-controller'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { Hex } from '@metamask/utils'; import { isCaipAssetType, @@ -49,10 +52,10 @@ import type { AccountTrackerUpdateStakedBalancesAction, } from './AccountTrackerController'; import { STAKING_CONTRACT_ADDRESS_BY_CHAINID } from './AssetsContractController'; -import { - AccountsApiBalanceFetcher, - type BalanceFetcher, - type ProcessedBalance, +import { AccountsApiBalanceFetcher } from './multi-chain-accounts-service/api-balance-fetcher'; +import type { + BalanceFetcher, + ProcessedBalance, } from './multi-chain-accounts-service/api-balance-fetcher'; import { RpcBalanceFetcher } from './rpc-service/rpc-balance-fetcher'; import type { TokenDetectionControllerAddDetectedTokensViaWsAction } from './TokenDetectionController'; @@ -130,7 +133,8 @@ export type AllowedActions = | AccountsControllerListAccountsAction | AccountTrackerControllerGetStateAction | AccountTrackerUpdateNativeBalancesAction - | AccountTrackerUpdateStakedBalancesAction; + | AccountTrackerUpdateStakedBalancesAction + | AuthenticationController.AuthenticationControllerGetBearerToken; export type AllowedEvents = | TokensControllerStateChangeEvent @@ -138,7 +142,8 @@ export type AllowedEvents = | NetworkControllerStateChangeEvent | KeyringControllerAccountRemovedEvent | AccountActivityServiceBalanceUpdatedEvent - | AccountActivityServiceStatusChangedEvent; + | AccountActivityServiceStatusChangedEvent + | AccountsControllerSelectedEvmAccountChangeEvent; export type TokenBalancesControllerMessenger = Messenger< typeof CONTROLLER, @@ -303,6 +308,9 @@ export class TokenBalancesController extends StaticIntervalPollingController<{ state: { tokenBalances: {}, ...state }, }); + // Normalize all account addresses to lowercase in existing state + this.#normalizeAccountAddresses(); + this.#platform = platform ?? 'extension'; this.#queryAllAccounts = queryMultipleAccounts; this.#accountsApiChainIds = accountsApiChainIds; @@ -346,6 +354,10 @@ export class TokenBalancesController extends StaticIntervalPollingController<{ 'KeyringController:accountRemoved', this.#onAccountRemoved, ); + this.messenger.subscribe( + 'AccountsController:selectedEvmAccountChange', + this.#onAccountChanged, + ); // Register action handlers for polling interval control this.messenger.registerActionHandler( @@ -371,6 +383,54 @@ export class TokenBalancesController extends StaticIntervalPollingController<{ ); } + /** + * Normalize all account addresses to lowercase and merge duplicates + * This handles migration from old state where addresses might be checksummed + */ + #normalizeAccountAddresses() { + const currentState = this.state.tokenBalances; + const normalizedBalances: TokenBalances = {}; + + // Iterate through all accounts and normalize to lowercase + for (const address of Object.keys(currentState)) { + const lowercaseAddress = address.toLowerCase() as ChecksumAddress; + const accountBalances = currentState[address as ChecksumAddress]; + + if (!accountBalances) { + continue; + } + + // If this lowercase address doesn't exist yet, create it + if (!normalizedBalances[lowercaseAddress]) { + normalizedBalances[lowercaseAddress] = {}; + } + + // Merge chain data + for (const chainId of Object.keys(accountBalances)) { + const chainIdKey = chainId as ChainIdHex; + + if (!normalizedBalances[lowercaseAddress][chainIdKey]) { + normalizedBalances[lowercaseAddress][chainIdKey] = {}; + } + + // Merge token balances (later values override earlier ones if duplicates exist) + Object.assign( + normalizedBalances[lowercaseAddress][chainIdKey], + accountBalances[chainIdKey], + ); + } + } + + // Only update if there were changes + if ( + Object.keys(currentState).length !== + Object.keys(normalizedBalances).length || + Object.keys(currentState).some((addr) => addr !== addr.toLowerCase()) + ) { + this.update(() => ({ tokenBalances: normalizedBalances })); + } + } + #chainIdsWithTokens(): ChainIdHex[] { return [ ...new Set([ @@ -640,6 +700,14 @@ export class TokenBalancesController extends StaticIntervalPollingController<{ ); const allAccounts = this.messenger.call('AccountsController:listAccounts'); + const jwtToken = await safelyExecuteWithTimeout( + () => { + return this.messenger.call('AuthenticationController:getBearerToken'); + }, + false, + 5000, + ); + const aggregated: ProcessedBalance[] = []; let remainingChains = [...targetChains]; @@ -658,6 +726,7 @@ export class TokenBalancesController extends StaticIntervalPollingController<{ queryAllAccounts: queryAllAccounts ?? this.#queryAllAccounts, selectedAccount: selected as ChecksumAddress, allAccounts, + jwtToken, }); if (result.balances && result.balances.length > 0) { @@ -744,17 +813,18 @@ export class TokenBalancesController extends StaticIntervalPollingController<{ // Update with actual fetched balances only if the value has changed aggregated.forEach(({ success, value, account, token, chainId }) => { if (success && value !== undefined) { + // Ensure all accounts we add/update are in lower-case + const lowerCaseAccount = account.toLowerCase() as ChecksumAddress; const newBalance = toHex(value); const tokenAddress = checksum(token); const currentBalance = - d.tokenBalances[account as ChecksumAddress]?.[chainId]?.[ - tokenAddress - ]; + d.tokenBalances[lowerCaseAccount]?.[chainId]?.[tokenAddress]; // Only update if the balance has actually changed if (currentBalance !== newBalance) { - ((d.tokenBalances[account as ChecksumAddress] ??= {})[chainId] ??= - {})[tokenAddress] = newBalance; + ((d.tokenBalances[lowerCaseAccount] ??= {})[chainId] ??= {})[ + tokenAddress + ] = newBalance; } } }); @@ -805,9 +875,7 @@ export class TokenBalancesController extends StaticIntervalPollingController<{ // Check if the chainId and token address match any staking contract const stakingContractAddress = - STAKING_CONTRACT_ADDRESS_BY_CHAINID[ - r.chainId as keyof typeof STAKING_CONTRACT_ADDRESS_BY_CHAINID - ]; + STAKING_CONTRACT_ADDRESS_BY_CHAINID[r.chainId]; return ( stakingContractAddress && stakingContractAddress.toLowerCase() === r.token.toLowerCase() @@ -1019,10 +1087,25 @@ export class TokenBalancesController extends StaticIntervalPollingController<{ return; } this.update((s) => { - delete s.tokenBalances[addr as ChecksumAddress]; + delete s.tokenBalances[addr]; }); }; + /** + * Handle account selection changes + * Triggers immediate balance fetch to ensure we have the latest balances + * since WebSocket only provides updates for changes going forward + */ + readonly #onAccountChanged = () => { + // Fetch balances for all chains with tokens when account changes + const chainIds = this.#chainIdsWithTokens(); + if (chainIds.length > 0) { + this.updateBalances({ chainIds }).catch(() => { + // Silently handle polling errors + }); + } + }; + // ──────────────────────────────────────────────────────────────────────────── // AccountActivityService integration helpers @@ -1173,7 +1256,7 @@ export class TokenBalancesController extends StaticIntervalPollingController<{ 'TokenDetectionController:addDetectedTokensViaWs', { tokensSlice: newTokens, - chainId: chainId as Hex, + chainId, }, ); } diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 15d8d6f59cc..0ff6afaa709 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -6,12 +6,11 @@ import { } from '@metamask/controller-utils'; import type { KeyringControllerState } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { getDefaultNetworkControllerState, @@ -25,10 +24,8 @@ import type { AutoManagedNetworkClient, CustomNetworkClientConfiguration, } from '@metamask/network-controller'; -import { - getDefaultPreferencesState, - type PreferencesState, -} from '@metamask/preferences-controller'; +import { getDefaultPreferencesState } from '@metamask/preferences-controller'; +import type { PreferencesState } from '@metamask/preferences-controller'; import type { Hex } from '@metamask/utils'; import BN from 'bn.js'; import nock from 'nock'; @@ -49,11 +46,11 @@ import { controllerName, mapChainIdWithTokenListMap, } from './TokenDetectionController'; -import { - getDefaultTokenListState, - type TokenListMap, - type TokenListState, - type TokenListToken, +import { getDefaultTokenListState } from './TokenListController'; +import type { + TokenListMap, + TokenListState, + TokenListToken, } from './TokenListController'; import type { Token } from './TokenRatesController'; import type { @@ -204,6 +201,7 @@ function buildTokenDetectionControllerMessenger( 'PreferencesController:getState', 'TokensController:addTokens', 'NetworkController:findNetworkClientIdByChainId', + 'AuthenticationController:getBearerToken', ], events: [ 'AccountsController:selectedEvmAccountChange', @@ -1192,6 +1190,7 @@ describe('TokenDetectionController', () => { '0xa', '0x89', '0x531', + '0x279f', ], selectedAddress: secondSelectedAccount.address, }); @@ -3748,15 +3747,7 @@ describe('TokenDetectionController', () => { options: { disabled: false, }, - }, - async ({ - controller, - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [chainId]: { timestamp: 0, @@ -3773,11 +3764,9 @@ describe('TokenDetectionController', () => { }, }, }, - }; - - mockTokenListGetState(tokenListState); - triggerTokenListStateChange(tokenListState); - + }, + }, + async ({ controller, callActionSpy }) => { await controller.addDetectedTokensViaWs({ tokensSlice: [mockTokenAddress], chainId: chainId as Hex, @@ -3813,27 +3802,16 @@ describe('TokenDetectionController', () => { options: { disabled: false, }, - }, - async ({ - controller, - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - // Empty token cache - token not found - const tokenListState = { - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [chainId]: { timestamp: 0, data: {}, }, }, - }; - - mockTokenListGetState(tokenListState); - triggerTokenListStateChange(tokenListState); - + }, + }, + async ({ controller, callActionSpy }) => { await controller.addDetectedTokensViaWs({ tokensSlice: [mockTokenAddress], chainId: chainId as Hex, @@ -3877,16 +3855,7 @@ describe('TokenDetectionController', () => { getSelectedAccount: selectedAccount, getAccount: selectedAccount, }, - }, - async ({ - controller, - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - // Set up token list with both tokens - const tokenListState = { - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [chainId]: { timestamp: 0, @@ -3912,11 +3881,9 @@ describe('TokenDetectionController', () => { }, }, }, - }; - - mockTokenListGetState(tokenListState); - triggerTokenListStateChange(tokenListState); - + }, + }, + async ({ controller, callActionSpy }) => { // Add both tokens via websocket await controller.addDetectedTokensViaWs({ tokensSlice: [mockTokenAddress, secondTokenAddress], @@ -3965,15 +3932,7 @@ describe('TokenDetectionController', () => { disabled: false, trackMetaMetricsEvent: mockTrackMetricsEvent, }, - }, - async ({ - controller, - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [chainId]: { timestamp: 0, @@ -3990,11 +3949,9 @@ describe('TokenDetectionController', () => { }, }, }, - }; - - mockTokenListGetState(tokenListState); - triggerTokenListStateChange(tokenListState); - + }, + }, + async ({ controller, callActionSpy }) => { await controller.addDetectedTokensViaWs({ tokensSlice: [mockTokenAddress], chainId: chainId as Hex, @@ -4031,15 +3988,7 @@ describe('TokenDetectionController', () => { options: { disabled: false, }, - }, - async ({ - controller, - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [chainId]: { timestamp: 0, @@ -4056,11 +4005,9 @@ describe('TokenDetectionController', () => { }, }, }, - }; - - mockTokenListGetState(tokenListState); - triggerTokenListStateChange(tokenListState); - + }, + }, + async ({ controller, callActionSpy }) => { // Call the public method directly on the controller instance await controller.addDetectedTokensViaWs({ tokensSlice: [mockTokenAddress], @@ -4157,7 +4104,9 @@ type WithControllerOptions = { mocks?: { getAccount?: InternalAccount; getSelectedAccount?: InternalAccount; + getBearerToken?: string; }; + mockTokenListState?: Partial; }; type WithControllerArgs = @@ -4177,7 +4126,7 @@ async function withController( ...args: WithControllerArgs ): Promise { const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; - const { options, isKeyringUnlocked, mocks } = rest; + const { options, isKeyringUnlocked, mocks, mockTokenListState } = rest; const messenger = buildRootMessenger(); const mockGetAccount = jest.fn(); @@ -4240,10 +4189,13 @@ async function withController( 'TokensController:getState', mockTokensState.mockReturnValue({ ...getDefaultTokensState() }), ); - const mockTokenListState = jest.fn(); + const mockTokenListStateFunc = jest.fn(); messenger.registerActionHandler( 'TokenListController:getState', - mockTokenListState.mockReturnValue({ ...getDefaultTokenListState() }), + mockTokenListStateFunc.mockReturnValue({ + ...getDefaultTokenListState(), + ...mockTokenListState, + }), ); const mockPreferencesState = jest.fn(); messenger.registerActionHandler( @@ -4253,6 +4205,14 @@ async function withController( }), ); + const mockGetBearerToken = jest.fn, []>(); + messenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + mockGetBearerToken.mockResolvedValue( + mocks?.getBearerToken ?? 'mock-jwt-token', + ), + ); + const mockFindNetworkClientIdByChainId = jest.fn(); messenger.registerActionHandler( 'NetworkController:findNetworkClientIdByChainId', @@ -4312,7 +4272,7 @@ async function withController( mockPreferencesState.mockReturnValue(state); }, mockTokenListGetState: (state: TokenListState) => { - mockTokenListState.mockReturnValue(state); + mockTokenListStateFunc.mockReturnValue(state); }, mockGetNetworkClientById: ( handler: ( diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index cd3193d5d77..99931a55b88 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -37,6 +37,7 @@ import type { PreferencesControllerGetStateAction, PreferencesControllerStateChangeEvent, } from '@metamask/preferences-controller'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { TransactionControllerTransactionConfirmedEvent } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { hexToNumber } from '@metamask/utils'; @@ -62,7 +63,7 @@ import type { } from './TokensController'; const DEFAULT_INTERVAL = 180000; -const ACCOUNTS_API_TIMEOUT_MS = 30000; +const ACCOUNTS_API_TIMEOUT_MS = 10000; type LegacyToken = { name: string; @@ -144,7 +145,8 @@ export type AllowedActions = | TokensControllerGetStateAction | TokensControllerAddDetectedTokensAction | TokensControllerAddTokensAction - | NetworkControllerFindNetworkClientIdByChainIdAction; + | NetworkControllerFindNetworkClientIdByChainIdAction + | AuthenticationController.AuthenticationControllerGetBearerToken; export type TokenDetectionControllerStateChangeEvent = ControllerStateChangeEvent; @@ -247,6 +249,7 @@ export class TokenDetectionController extends StaticIntervalPollingController hexToNumber(chainId)); @@ -266,6 +269,7 @@ export class TokenDetectionController extends StaticIntervalPollingController { @@ -613,6 +618,7 @@ export class TokenDetectionController extends StaticIntervalPollingController( + () => { + return this.messenger.call('AuthenticationController:getBearerToken'); + }, + false, + 5000, + ); + let supportedNetworks; if (this.#accountsAPI.isAccountsAPIEnabled && this.#useExternalServices()) { supportedNetworks = await this.#accountsAPI.getSupportedNetworks(); @@ -734,6 +748,7 @@ export class TokenDetectionController extends StaticIntervalPollingController toHex(chainId), - ) as Hex[]; + ); this.#addChainsToRpcDetection( chainsToDetectUsingRpc, unprocessedChainIds, @@ -843,21 +858,29 @@ export class TokenDetectionController extends StaticIntervalPollingController { // Fetch balances for multiple chain IDs at once const apiResponse = await this.#accountsAPI - .getMultiNetworksBalances(selectedAddress, chainIds, supportedNetworks) + .getMultiNetworksBalances( + selectedAddress, + chainIds, + supportedNetworks, + jwtToken, + ) .catch(() => null); if (apiResponse === null) { diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index 9d0f5cecae0..b6a03aa7b52 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -6,12 +6,11 @@ import { toHex, InfuraNetworkType, } from '@metamask/controller-utils'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { NetworkState } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; @@ -103,13 +102,11 @@ const sampleMainnetTokenList = [ }, ]; -const sampleMainnetTokensChainsCache = sampleMainnetTokenList.reduce( - (output, current) => { +const sampleMainnetTokensChainsCache = + sampleMainnetTokenList.reduce((output, current) => { output[current.address] = current; return output; - }, - {} as TokenListMap, -); + }, {}); const sampleBinanceTokenList = [ { @@ -147,13 +144,11 @@ const sampleBinanceTokenList = [ }, ]; -const sampleBinanceTokensChainsCache = sampleBinanceTokenList.reduce( - (output, current) => { +const sampleBinanceTokensChainsCache = + sampleBinanceTokenList.reduce((output, current) => { output[current.address] = current; return output; - }, - {} as TokenListMap, -); + }, {}); const sampleSingleChainState = { tokenList: { @@ -316,13 +311,11 @@ const sampleSepoliaTokenList = [ }, ]; -const sampleSepoliaTokensChainCache = sampleSepoliaTokenList.reduce( - (output, current) => { +const sampleSepoliaTokensChainCache = + sampleSepoliaTokenList.reduce((output, current) => { output[current.address] = current; return output; - }, - {} as TokenListMap, -); + }, {}); const sampleTwoChainState = { tokenList: { diff --git a/packages/assets-controllers/src/TokenRatesController.test.ts b/packages/assets-controllers/src/TokenRatesController.test.ts index 0ed2dea39d3..2ab2ae504f9 100644 --- a/packages/assets-controllers/src/TokenRatesController.test.ts +++ b/packages/assets-controllers/src/TokenRatesController.test.ts @@ -1,58 +1,40 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - ChainId, - InfuraNetworkType, - NetworksTicker, - toChecksumHexAddress, - toHex, -} from '@metamask/controller-utils'; -import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { ChainId, toChecksumHexAddress } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { NetworkClientConfiguration, NetworkClientId, + NetworkConfiguration, NetworkState, } from '@metamask/network-controller'; import { getDefaultNetworkControllerState } from '@metamask/network-controller'; -import type { Hex } from '@metamask/utils'; -import { add0x } from '@metamask/utils'; -import assert from 'assert'; +import type { CaipAssetType, Hex } from '@metamask/utils'; +import { add0x, KnownCaipNamespace } from '@metamask/utils'; import type { Patch } from 'immer'; -import { useFakeTimers } from 'sinon'; import { TOKEN_PRICES_BATCH_SIZE } from './assetsUtil'; import type { AbstractTokenPricesService, - TokenPrice, - TokenPricesByTokenAddress, + EvmAssetWithMarketData, } from './token-prices-service/abstract-token-prices-service'; +import { ZERO_ADDRESS } from './token-prices-service/codefi-v2'; import { controllerName, TokenRatesController } from './TokenRatesController'; import type { + MarketDataDetails, Token, TokenRatesControllerMessenger, TokenRatesControllerState, } from './TokenRatesController'; import { getDefaultTokensState } from './TokensController'; import type { TokensControllerState } from './TokensController'; -import { advanceTime } from '../../../tests/helpers'; -import { createMockInternalAccount } from '../../accounts-controller/src/tests/mocks'; -import { - buildCustomNetworkClientConfiguration, - buildMockGetNetworkClientById, - buildNetworkConfiguration, -} from '../../network-controller/tests/helpers'; - -const defaultSelectedAddress = '0x0000000000000000000000000000000000000001'; -const defaultSelectedAccount = createMockInternalAccount({ - address: defaultSelectedAddress, -}); -const mockTokenAddress = '0x0000000000000000000000000000000000000010'; +import { flushPromises } from '../../../tests/helpers'; + +const defaultSelectedAddress = '0x1111111111111111111111111111111111111111'; type AllTokenRatesControllerActions = MessengerActions; @@ -86,34 +68,14 @@ function buildTokenRatesControllerMessenger( }); messenger.delegate({ messenger: tokenRatesControllerMessenger, - actions: [ - 'TokensController:getState', - 'NetworkController:getNetworkClientById', - 'NetworkController:getState', - 'AccountsController:getAccount', - 'AccountsController:getSelectedAccount', - ], - events: [ - 'TokensController:stateChange', - 'NetworkController:stateChange', - 'AccountsController:selectedEvmAccountChange', - ], + actions: ['TokensController:getState', 'NetworkController:getState'], + events: ['TokensController:stateChange', 'NetworkController:stateChange'], }); return tokenRatesControllerMessenger; } describe('TokenRatesController', () => { describe('constructor', () => { - let clock: sinon.SinonFakeTimers; - - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); - }); - - afterEach(() => { - clock.restore(); - }); - it('should set default state', async () => { await withController(async ({ controller }) => { expect(controller.state).toStrictEqual({ @@ -121,2703 +83,957 @@ describe('TokenRatesController', () => { }); }); }); + }); + + describe('updateExchangeRates', () => { + it('does not fetch when disabled', async () => { + const tokenPricesService = buildMockTokenPricesService(); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - it('should not poll by default', async () => { - const fetchSpy = jest.spyOn(globalThis, 'fetch'); await withController( { options: { - interval: 100, + tokenPricesService, + disabled: true, }, }, async ({ controller }) => { - expect(controller.state).toStrictEqual({ - marketData: {}, - }); + await controller.updateExchangeRates([ + { + chainId: '0x1', + nativeCurrency: 'ETH', + }, + ]); + + expect(tokenPricesService.fetchTokenPrices).not.toHaveBeenCalled(); }, ); - await advanceTime({ clock, duration: 500 }); - - expect(fetchSpy).not.toHaveBeenCalled(); - }); - }); - - describe('TokensController::stateChange', () => { - let clock: sinon.SinonFakeTimers; - - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); - }); - - afterEach(() => { - clock.restore(); }); - describe('when legacy polling is active', () => { - it('should update exchange rates when any of the addresses in the "all tokens" collection change', async () => { - const tokenAddresses = ['0xE1', '0xE2']; - await withController( - { - mockTokensControllerState: { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRatesByChainId') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[1], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }); - - // Once when starting, and another when tokens state changes - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(2); - }, - ); - }); - - it('should update exchange rates when any of the addresses in the "all tokens" collection change with invalid addresses', async () => { - const tokenAddresses = ['0xinvalidAddress']; - await withController( - { - mockTokensControllerState: { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRatesByChainId') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[1], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }); + it('fetches rates for tokens in one batch', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; - // Once when starting, and another when tokens state changes - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(2); - }, - ); + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - it('should update exchange rates when any of the addresses in the "all detected tokens" collection change', async () => { - const tokenAddresses = ['0xE1', '0xE2']; - await withController( - { - mockTokensControllerState: { - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, + await withController( + { + options: { + tokenPricesService, }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRatesByChainId') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[1], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, + mockTokensControllerState: { + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], }, - }); - // Once when starting, and another when tokens state changes - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(2); - }, - ); - }); - - it('should not update exchange rates if both the "all tokens" or "all detected tokens" are exactly the same', async () => { - const tokensState = { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], }, }, - }; - await withController( - { - mockTokensControllerState: { - ...tokensState, + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId, + nativeCurrency, }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - ...tokensState, - }); - - // Once when starting, and that's it - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); - }, - ); - }); + ]); - it('should not update exchange rates if all of the tokens in "all tokens" just move to "all detected tokens"', async () => { - const tokens = { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledWith({ + assets: [ { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], + chainId, + tokenAddress: '0x0000000000000000000000000000000000000000', }, - ], - }, - }; - await withController( - { - mockTokensControllerState: { - allTokens: tokens, - }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allDetectedTokens: tokens, - }); - - // Once when starting, and that's it - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); - }, - ); - }); - - it('should not update exchange rates if a new token is added to "all detected tokens" but is already present in "all tokens"', async () => { - const tokens = { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], + chainId, + tokenAddress: '0x0000000000000000000000000000000000000001', }, ], - }, - }; - await withController( - { - mockTokensControllerState: { - allTokens: tokens, - }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allTokens: tokens, - allDetectedTokens: tokens, - }); - - // Once when starting, and that's it - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); - }, - ); - }); + currency: nativeCurrency, + }); - it('should not update exchange rates if a new token is added to "all tokens" but is already present in "all detected tokens"', async () => { - const tokens = { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }; - await withController( - { - mockTokensControllerState: { - allDetectedTokens: tokens, + expect(controller.state.marketData).toStrictEqual({ + '0x1': { + '0x0000000000000000000000000000000000000000': + expect.objectContaining({ + currency: nativeCurrency, + price: 0.001, + }), + '0x0000000000000000000000000000000000000001': + expect.objectContaining({ + currency: nativeCurrency, + price: 0.002, + }), }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allTokens: tokens, - allDetectedTokens: tokens, - }); - - // Once when starting, and that's it - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); - }, - ); - }); + }); + }, + ); + }); - it('should not update exchange rates if none of the addresses in "all tokens" or "all detected tokens" change, even if other parts of the token change', async () => { - await withController( - { - mockTokensControllerState: { - ...getDefaultTokensState(), - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 3, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 7, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }); + it('fetches rates for all tokens in batches', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; - // Once when starting, and that's it - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); - }, - ); + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - it('should not update exchange rates if none of the addresses in "all tokens" or "all detected tokens" change, when normalized to checksum addresses', async () => { - await withController( - { - mockTokensControllerState: { - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: '0x0EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE2', - decimals: 3, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: '0x0eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee2', - decimals: 7, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }); - - // Once when starting, and that's it - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); - }, - ); + const tokenAddresses = [...new Array(200).keys()] + .map(buildAddress) + .sort(); + const tokens = tokenAddresses.map((tokenAddress) => { + return buildToken({ address: tokenAddress }); }); - - it('should not update exchange rates if any of the addresses in "all tokens" or "all detected tokens" merely change order', async () => { - await withController( - { - mockTokensControllerState: { - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: '0xE1', - decimals: 0, - symbol: '', - aggregators: [], - }, - { - address: '0xE2', - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, + await withController( + { + options: { + tokenPricesService, + }, + mockTokensControllerState: { + allTokens: { + [chainId]: { + [defaultSelectedAddress]: tokens, }, }, }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: '0xE2', - decimals: 0, - symbol: '', - aggregators: [], - }, - { - address: '0xE1', - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }); + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId, + nativeCurrency, + }, + ]); + const numBatches = Math.ceil( + tokenAddresses.length / TOKEN_PRICES_BATCH_SIZE, + ); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + numBatches, + ); - // Once when starting, and that's it - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); - }, - ); - }); + for (let i = 1; i <= numBatches; i++) { + expect(tokenPricesService.fetchTokenPrices).toHaveBeenNthCalledWith( + i, + { + assets: tokenAddresses + .slice( + (i - 1) * TOKEN_PRICES_BATCH_SIZE, + i * TOKEN_PRICES_BATCH_SIZE, + ) + .map((tokenAddress) => ({ + chainId, + tokenAddress, + })), + currency: nativeCurrency, + }, + ); + } + }, + ); }); - describe('when legacy polling is inactive', () => { - it('should not update exchange rates when any of the addresses in the "all tokens" collection change', async () => { - const tokenAddresses = ['0xE1', '0xE2']; - await withController( - { - mockTokensControllerState: { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[1], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }); + it('leaves unsupported chain state keys empty', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); - }, - ); + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, + validateChainIdSupported: (_chainId: unknown): _chainId is Hex => false, }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - it('should not update exchange rates when any of the addresses in the "all detected tokens" collection change', async () => { - const tokenAddresses = ['0xE1', '0xE2']; - await withController( - { - mockTokensControllerState: { - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[1], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }); - - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); + await withController( + { + options: { + tokenPricesService, }, - ); - }); - }); - }); - - describe('NetworkController::stateChange', () => { - let clock: sinon.SinonFakeTimers; + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId, + nativeCurrency, + }, + ]); - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); + expect(tokenPricesService.fetchTokenPrices).not.toHaveBeenCalled(); + expect(controller.state.marketData).toStrictEqual({ + [chainId]: {}, + }); + }, + ); }); - afterEach(() => { - clock.restore(); - }); + it('fetches rates for unsupported native currencies', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; - describe('when polling is active', () => { - it('should update exchange rates when ticker changes', async () => { - await withController( - { - options: { - interval: 100, + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: async ({ currency }) => { + return [ + { + tokenAddress: ZERO_ADDRESS, + chainId, + assetId: `${KnownCaipNamespace.Eip155}:1/slip44:60`, + currency, + price: 50, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 60, + allTimeLow: 40, + circulatingSupply: 2000, + dilutedMarketCap: 1000, + high1d: 55, + low1d: 45, + marketCap: 2000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1337), - ticker: 'NEW', - }), + { + tokenAddress: '0x0000000000000000000000000000000000000001', + chainId, + assetId: `${KnownCaipNamespace.Eip155}:1/erc20:0x0000000000000000000000000000000000000001`, + currency, + price: 100, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 200, + allTimeLow: 80, + circulatingSupply: 2000, + dilutedMarketCap: 500, + high1d: 110, + low1d: 95, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, }, - }, - async ({ controller, triggerNetworkStateChange }) => { - await controller.start(ChainId.mainnet, 'ETH'); - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); - - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); - }, - ); + ]; + }, + validateCurrencySupported: (_currency: unknown): _currency is string => + false, }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - it('should not update exchange rates when chain ID changes', async () => { - await withController( - { - options: { - interval: 100, - }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1338), - ticker: 'TEST', - }), - }, - }, - async ({ controller, triggerNetworkStateChange }) => { - await controller.start(ChainId.mainnet, 'ETH'); - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); - - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); + await withController( + { + options: { + tokenPricesService, }, - ); - }); - - it('should clear marketData in state when ticker changes', async () => { - await withController( - { - options: { - interval: 100, - state: { - marketData: { - [ChainId.mainnet]: { - '0x02': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, + mockTokensControllerState: { + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', }, - }, + ], }, }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1337), - ticker: 'NEW', - }), - }, }, - async ({ controller, triggerNetworkStateChange }) => { - await controller.start(ChainId.mainnet, 'ETH'); - jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId, + nativeCurrency, + }, + ]); - expect(controller.state.marketData).toStrictEqual({ - '0x1': { - '0x0000000000000000000000000000000000000000': { - currency: 'ETH', - }, + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledWith({ + assets: [ + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000000', }, - }); - }, - ); - }); - - it('should not clear marketData state when chain ID changes', async () => { - await withController( - { - options: { - interval: 100, - state: { - marketData: { - [ChainId.mainnet]: { - '0x02': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, - }, - }, + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000001', }, - }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1338), - ticker: 'TEST', - }), - }, - }, - async ({ controller, triggerNetworkStateChange }) => { - await controller.start(ChainId.mainnet, 'ETH'); - jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); + ], + currency: 'usd', + }); - expect(controller.state.marketData).toStrictEqual({ - '0x1': { - '0x0000000000000000000000000000000000000000': { - currency: 'ETH', - }, + expect(controller.state.marketData).toStrictEqual({ + '0x1': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: '0x1', + assetId: 'eip155:1/slip44:60', + currency: 'ETH', + price: 1, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 1.2, + allTimeLow: 0.8, + circulatingSupply: 2000, + dilutedMarketCap: 20, + high1d: 1.1, + low1d: 0.9, + marketCap: 40, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 2, }, - }); - }, - ); - }); - - it('should update exchange rates when network state changes without adding a new network', async () => { - await withController( - { - options: { - interval: 100, - }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: ChainId.mainnet, - ticker: NetworksTicker.mainnet, - }), - }, - }, - async ({ controller, triggerNetworkStateChange }) => { - await controller.start(ChainId.mainnet, 'ETH'); - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerNetworkStateChange( - { - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', + '0x0000000000000000000000000000000000000001': { + tokenAddress: '0x0000000000000000000000000000000000000001', + chainId: '0x1', + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000001', + currency: 'ETH', + price: 2, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4, + allTimeLow: 1.6, + circulatingSupply: 2000, + dilutedMarketCap: 10, + high1d: 2.2, + low1d: 1.9, + marketCap: 20, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 2, }, - [ - { - op: 'add', - path: ['networkConfigurationsByChainId', ChainId.mainnet], - }, - ], - ); - expect(updateExchangeRatesSpy).toHaveBeenCalled(); - }, - ); - }); - }); - - describe('when polling is inactive', () => { - it('should not update exchange rates when ticker changes', async () => { - await withController( - { - options: { - interval: 100, - }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1337), - ticker: 'NEW', - }), }, - }, - async ({ controller, triggerNetworkStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); + }); + }, + ); + }); - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); - }, - ); - }); + it('does not convert prices when the native currency fallback price is 0', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; - it('should not update exchange rates when chain ID changes', async () => { - await withController( - { - options: { - interval: 100, + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: async ({ currency }) => { + return [ + { + tokenAddress: ZERO_ADDRESS, + chainId, + assetId: `${KnownCaipNamespace.Eip155}:1/slip44:60`, + currency, + price: 0, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 60, + allTimeLow: 40, + circulatingSupply: 2000, + dilutedMarketCap: 1000, + high1d: 55, + low1d: 45, + marketCap: 2000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1338), - ticker: 'TEST', - }), + { + tokenAddress: '0x0000000000000000000000000000000000000001', + chainId, + assetId: `${KnownCaipNamespace.Eip155}:1/erc20:0x0000000000000000000000000000000000000001`, + currency, + price: 100, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 200, + allTimeLow: 80, + circulatingSupply: 2000, + dilutedMarketCap: 500, + high1d: 110, + low1d: 95, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, }, - }, - async ({ controller, triggerNetworkStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); - - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); - }, - ); + ]; + }, + validateCurrencySupported: (_currency: unknown): _currency is string => + false, }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - it('should not clear marketData state when ticker changes', async () => { - await withController( - { - options: { - interval: 100, - state: { - marketData: { - [ChainId.mainnet]: { - '0x02': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, + await withController( + { + options: { + tokenPricesService, + }, + mockTokensControllerState: { + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', }, - }, + ], }, }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1337), - ticker: 'NEW', - }), - }, }, - async ({ controller, triggerNetworkStateChange }) => { - jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); - - expect(controller.state.marketData).toStrictEqual({ - '0x1': { - '0x02': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, - }, - }); - }, - ); - }); + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId, + nativeCurrency, + }, + ]); - it('should not clear marketData state when chain ID changes', async () => { - await withController( - { - options: { - interval: 100, - state: { - marketData: { - [ChainId.mainnet]: { - '0x02': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, - }, - }, + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledWith({ + assets: [ + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000000', }, - }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1338), - ticker: 'TEST', - }), - }, - }, - async ({ controller, triggerNetworkStateChange }) => { - jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); - - expect(controller.state.marketData).toStrictEqual({ - '0x1': { - '0x02': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000001', }, - }); - }, - ); - }); - }); + ], + currency: 'usd', + }); - it('removes state when networks are deleted', async () => { - const marketData = { - [ChainId.mainnet]: { - '0x123456': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, + expect(controller.state.marketData).toStrictEqual({ + '0x1': {}, + }); }, - [ChainId['linea-mainnet']]: { - '0x789': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, + ); + }); + + it('does not convert prices when the native currency fallback price is missing', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; + + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: async ({ currency }) => { + return [ + { + tokenAddress: '0x0000000000000000000000000000000000000001', + chainId, + assetId: `${KnownCaipNamespace.Eip155}:1/erc20:0x0000000000000000000000000000000000000001`, + currency, + price: 100, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 200, + allTimeLow: 80, + circulatingSupply: 2000, + dilutedMarketCap: 500, + high1d: 110, + low1d: 95, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + ]; }, - } as const; + validateCurrencySupported: (_currency: unknown): _currency is string => + false, + }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); await withController( { options: { - state: { - marketData, + tokenPricesService, + }, + mockTokensControllerState: { + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], + }, }, }, }, - async ({ controller, triggerNetworkStateChange }) => { - // Verify initial state with both networks - expect(controller.state.marketData).toStrictEqual(marketData); - - triggerNetworkStateChange( + async ({ controller }) => { + await controller.updateExchangeRates([ { - selectedNetworkClientId: 'mainnet', - networkConfigurationsByChainId: {}, - } as NetworkState, - [ + chainId, + nativeCurrency, + }, + ]); + + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledWith({ + assets: [ { - op: 'remove', - path: [ - 'networkConfigurationsByChainId', - ChainId['linea-mainnet'], - ], + chainId, + tokenAddress: '0x0000000000000000000000000000000000000000', + }, + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000001', }, ], - ); + currency: 'usd', + }); - // Verify linea removed expect(controller.state.marketData).toStrictEqual({ - [ChainId.mainnet]: marketData[ChainId.mainnet], + '0x1': {}, }); }, ); }); }); - describe('PreferencesController::stateChange', () => { - let clock: sinon.SinonFakeTimers; + describe('_executePoll', () => { + it('fetches rates for the given chains', async () => { + await withController({}, async ({ controller }) => { + jest.spyOn(controller, 'updateExchangeRates'); - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); - }); - - afterEach(() => { - clock.restore(); - }); + await controller._executePoll({ chainIds: ['0x1'] }); - describe('when polling is active', () => { - it('should not update exchange rates when selected address changes', async () => { - const alternateSelectedAddress = - '0x0000000000000000000000000000000000000002'; - const alternateSelectedAccount = createMockInternalAccount({ - address: alternateSelectedAddress, - }); - await withController( + expect(controller.updateExchangeRates).toHaveBeenCalledWith([ { - options: { - interval: 100, - }, - mockTokensControllerState: { - allTokens: { - '0x1': { - [alternateSelectedAddress]: [ - { - address: '0x02', - decimals: 0, - symbol: '', - aggregators: [], - }, - { - address: '0x03', - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller, triggerSelectedAccountChange }) => { - await controller.start(ChainId.mainnet, 'ETH'); - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerSelectedAccountChange(alternateSelectedAccount); - - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); + chainId: '0x1', + nativeCurrency: 'ETH', }, - ); + ]); }); }); - describe('when polling is inactive', () => { - it('does not update exchange rates when selected account changes', async () => { - const alternateSelectedAddress = - '0x0000000000000000000000000000000000000002'; - const alternateSelectedAccount = createMockInternalAccount({ - address: alternateSelectedAddress, - }); - await withController( - { - options: { - interval: 100, - }, - mockTokensControllerState: { - allTokens: { - '0x1': { - [alternateSelectedAddress]: [ - { - address: '0x02', - decimals: 0, - symbol: '', - aggregators: [], - }, - { - address: '0x03', - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller, triggerSelectedAccountChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerSelectedAccountChange(alternateSelectedAccount); - - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); + it('does not include chains with no network configuration', async () => { + await withController( + { + mockNetworkState: { + networkConfigurationsByChainId: {}, }, - ); - }); - }); - }); - - describe('legacy polling', () => { - let clock: sinon.SinonFakeTimers; + }, + async ({ controller }) => { + jest.spyOn(controller, 'updateExchangeRates'); - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); - }); + await controller._executePoll({ chainIds: ['0x1'] }); - afterEach(() => { - clock.restore(); + expect(controller.updateExchangeRates).toHaveBeenCalledWith([]); + }, + ); }); + }); - describe('start', () => { - it('should poll and update rate in the right interval', async () => { - const interval = 100; - const tokenPricesService = buildMockTokenPricesService(); - jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - await withController( - { - options: { - interval, - tokenPricesService, - }, - mockTokensControllerState: { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller }) => { - await controller.start(ChainId.mainnet, 'ETH'); - - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( - 1, - ); - - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( - 2, - ); + describe('TokensController:stateChange', () => { + it('fetches rates for all updated chains', async () => { + jest.useFakeTimers(); + const chainId = '0x1'; + const nativeCurrency = 'ETH'; - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( - 3, - ); - }, - ); + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, }); - }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - describe('stop', () => { - it('should stop polling', async () => { - const interval = 100; - const tokenPricesService = buildMockTokenPricesService(); - jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - await withController( - { - options: { - interval, - tokenPricesService, + await withController( + { + options: { + tokenPricesService, + }, + }, + async ({ controller, triggerTokensStateChange }) => { + triggerTokensStateChange({ + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], + }, }, - mockTokensControllerState: { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, + allDetectedTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + { + address: '0x0000000000000000000000000000000000000002', + decimals: 0, + symbol: 'TOK2', + }, + ], }, }, - }, - async ({ controller }) => { - await controller.start(ChainId.mainnet, 'ETH'); - - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( - 1, - ); - - controller.stop(); + allIgnoredTokens: {}, + }); - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( - 1, - ); - }, - ); - }); - }); - }); + jest.advanceTimersToNextTimer(); + await flushPromises(); - describe('polling by networkClientId', () => { - let clock: sinon.SinonFakeTimers; + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledWith({ + assets: [ + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000000', + }, + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000001', + }, + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000002', + }, + ], + currency: nativeCurrency, + }); - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); + expect(controller.state.marketData).toStrictEqual({ + [chainId]: { + '0x0000000000000000000000000000000000000000': + expect.objectContaining({ + currency: nativeCurrency, + price: 0.001, + }), + '0x0000000000000000000000000000000000000001': + expect.objectContaining({ + currency: nativeCurrency, + price: 0.002, + }), + '0x0000000000000000000000000000000000000002': + expect.objectContaining({ + currency: nativeCurrency, + price: 0.003, + }), + }, + }); + }, + ); }); - afterEach(() => { - clock.restore(); - }); + it('does not fetch when disabled', async () => { + jest.useFakeTimers(); + const chainId = '0x1'; - it('should poll on the right interval', async () => { - const interval = 100; - const tokenPricesService = buildMockTokenPricesService(); - jest.spyOn(tokenPricesService, 'fetchTokenPrices'); await withController( { options: { - interval, - tokenPricesService, + disabled: true, }, - mockTokensControllerState: { + }, + async ({ controller, triggerTokensStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); + + triggerTokensStateChange({ allTokens: { - [ChainId.mainnet]: { + [chainId]: { [defaultSelectedAddress]: [ { - address: mockTokenAddress, + address: '0x0000000000000000000000000000000000000001', decimals: 0, - symbol: '', - aggregators: [], + symbol: 'TOK1', }, ], }, }, - }, - }, - async ({ controller }) => { - controller.startPolling({ - chainIds: [ChainId.mainnet], + allDetectedTokens: {}, + allIgnoredTokens: {}, }); - await advanceTime({ clock, duration: 0 }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); - - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(2); + jest.advanceTimersToNextTimer(); + await flushPromises(); - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(3); + expect(controller.updateExchangeRates).not.toHaveBeenCalled(); }, ); }); - describe('updating state on poll', () => { - describe('when the native currency is supported', () => { - it('returns the exchange rates directly', async () => { - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, - validateCurrencySupported(currency: unknown): currency is string { - return currency === 'ETH'; - }, - }); - const interval = 100; - await withController( - { - options: { - interval, - tokenPricesService, - }, - mockTokensControllerState: { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: '0x02', - decimals: 0, - symbol: '', - aggregators: [], - }, - { - address: '0x03', - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller }) => { - controller.startPolling({ - chainIds: [ChainId.mainnet], - }); - await advanceTime({ clock, duration: 0 }); - - expect(controller.state).toStrictEqual({ - marketData: { - [ChainId.mainnet]: { - '0x02': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, - '0x03': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x03', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.002, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, - }, - }, - }); - }, - ); - }); - - describe('when the native currency is not supported', () => { - const fallbackRate = 0.5; - it('returns the exchange rates using ETH as a fallback currency', async () => { - const nativeTokenPriceInUSD = 2; - // For mainnet (0x1), native token address is 0x0000...0000 - const nativeTokenAddress = - '0x0000000000000000000000000000000000000000'; - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: async ({ tokenAddresses, currency }) => { - // Handle native token price request (empty tokenAddresses array) - if (tokenAddresses.length === 0 && currency === 'usd') { - return { - [nativeTokenAddress]: { - tokenAddress: nativeTokenAddress, - currency: 'usd', - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: nativeTokenPriceInUSD, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, - }; - } - // Handle regular token prices - return fetchTokenPricesWithIncreasingPriceForEachToken({ - tokenAddresses, - currency, - }); - }, - validateCurrencySupported(currency: unknown): currency is string { - return currency !== 'LOL'; - }, - }); - await withController( - { - options: { - tokenPricesService, - }, - mockNetworkState: { - networkConfigurationsByChainId: { - [ChainId.mainnet]: buildNetworkConfiguration({ - nativeCurrency: 'LOL', - }), - }, - }, - mockTokensControllerState: { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: '0x02', - decimals: 0, - symbol: '', - aggregators: [], - }, - { - address: '0x03', - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller }) => { - controller.startPolling({ - chainIds: [ChainId.mainnet], - }); - // flush promises and advance setTimeouts they enqueue 3 times - // needed because fetch() doesn't resolve immediately, so any - // downstream promises aren't flushed until the next advanceTime loop - await advanceTime({ clock, duration: 1, stepSize: 1 / 3 }); - expect(controller.state.marketData).toStrictEqual({ - [ChainId.mainnet]: { - // token price in LOL = (token price in ETH) * (ETH value in LOL) - '0x02': { - tokenAddress: '0x02', - currency: 'LOL', - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: 4000 * fallbackRate, - allTimeLow: 900 * fallbackRate, - circulatingSupply: 2000, - dilutedMarketCap: 100 * fallbackRate, - high1d: 200 * fallbackRate, - low1d: 100 * fallbackRate, - marketCap: 1000 * fallbackRate, - marketCapPercentChange1d: 100, - price: (1 / 1000) * fallbackRate, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100 * fallbackRate, - }, - '0x03': { - tokenAddress: '0x03', - currency: 'LOL', - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: 4000 * fallbackRate, - allTimeLow: 900 * fallbackRate, - circulatingSupply: 2000, - dilutedMarketCap: 100 * fallbackRate, - high1d: 200 * fallbackRate, - low1d: 100 * fallbackRate, - marketCap: 1000 * fallbackRate, - marketCapPercentChange1d: 100, - price: (2 / 1000) * fallbackRate, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100 * fallbackRate, - }, - }, - }); - controller.stopAllPolling(); - }, - ); - }); + it('does not include chains when tokens are not updated', async () => { + jest.useFakeTimers(); + const chainId = '0x1'; - it('returns the an empty object when market does not exist for pair', async () => { - // New implementation returns empty object when native token price is unavailable - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: async ({ tokenAddresses, currency }) => { - // Return empty for native token price request in USD - // This simulates the case where native token price is unavailable - if (tokenAddresses.length === 0 && currency === 'usd') { - return {}; - } - // For regular token requests, also return empty to simulate failure - if (currency === 'usd') { - return fetchTokenPricesWithIncreasingPriceForEachToken({ - tokenAddresses, - currency, - }); - } - // Should not get here since we use 'usd' as fallback - return {}; - }, - validateCurrencySupported(currency: unknown): currency is string { - return currency !== 'LOL'; - }, - }); - await withController( - { - options: { - tokenPricesService, - }, - mockNetworkState: { - networkConfigurationsByChainId: { - [ChainId.mainnet]: buildNetworkConfiguration({ - nativeCurrency: 'LOL', - }), - }, - }, - mockTokensControllerState: { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: '0x02', - decimals: 0, - symbol: '', - aggregators: [], - }, - { - address: '0x03', - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, + await withController( + { + mockTokensControllerState: { + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', }, - }, - }, - async ({ controller }) => { - controller.startPolling({ - chainIds: [ChainId.mainnet], - }); - // flush promises and advance setTimeouts they enqueue 3 times - // needed because fetch() doesn't resolve immediately, so any - // downstream promises aren't flushed until the next advanceTime loop - await advanceTime({ clock, duration: 1, stepSize: 1 / 3 }); - - expect(controller.state.marketData).toStrictEqual({ - [ChainId.mainnet]: {}, - }); - controller.stopAllPolling(); + ], }, - ); - }); - }); - }); - - it('should stop polling', async () => { - const interval = 100; - const tokenPricesService = buildMockTokenPricesService(); - jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - await withController( - { - options: { - tokenPricesService, }, - mockTokensControllerState: { - allTokens: { - '0x1': { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }, + }, + async ({ controller, triggerTokensStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); + + triggerTokensStateChange({ + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], }, }, - }, - async ({ controller }) => { - const pollingToken = controller.startPolling({ - chainIds: [ChainId.mainnet], - }); - await advanceTime({ clock, duration: 0 }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( - 1, - ); + allDetectedTokens: {}, + allIgnoredTokens: {}, + }); - controller.stopPollingByPollingToken(pollingToken); + jest.advanceTimersToNextTimer(); + await flushPromises(); - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( - 1, - ); - }, - ); - }); + expect(controller.updateExchangeRates).toHaveBeenCalledWith([]); + }, + ); }); - // The TokenRatesController has two methods for updating exchange rates: - // `updateExchangeRates` and `updateExchangeRatesByChainId`. They are the same - // except in how the inputs are specified. `updateExchangeRates` gets the - // inputs from controller configuration, whereas `updateExchangeRatesByChainId` - // accepts the inputs as parameters. - // - // Here we test both of these methods using the same test cases. The - // differences between them are abstracted away by the helper function - // `callUpdateExchangeRatesMethod`. - describe.each([ - 'updateExchangeRates' as const, - 'updateExchangeRatesByChainId' as const, - ])('%s', (method) => { - it('does not update state when disabled', async () => { - await withController( - {}, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - const tokenAddress = '0x0000000000000000000000000000000000000001'; - controller.disable(); - await callUpdateExchangeRatesMethod({ - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddress, - decimals: 18, - symbol: 'TST', - aggregators: [], - }, - ], - }, - }, - chainId: ChainId.mainnet, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: 'ETH', - selectedNetworkClientId: InfuraNetworkType.mainnet, - }); + it('does not include chains with no network configuration', async () => { + jest.useFakeTimers(); + const chainId = '0x1'; - expect(controller.state.marketData).toStrictEqual({}); + await withController( + { + mockNetworkState: { + networkConfigurationsByChainId: {}, }, - ); - }); + }, + async ({ controller, triggerTokensStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); - it('does not update state if there are no tokens for the given chain', async () => { - await withController( - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - const tokenAddress = '0x0000000000000000000000000000000000000001'; - controller.enable(); - await callUpdateExchangeRatesMethod({ - allTokens: { - // These tokens are on a different chain - [toHex(2)]: { - [defaultSelectedAddress]: [ - { - address: tokenAddress, - decimals: 18, - symbol: 'TST', - aggregators: [], - }, - ], - }, + triggerTokensStateChange({ + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], }, - chainId: ChainId.mainnet, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: 'ETH', - selectedNetworkClientId: InfuraNetworkType.mainnet, - }); + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }); + + jest.advanceTimersToNextTimer(); + await flushPromises(); + + expect(controller.updateExchangeRates).toHaveBeenCalledWith([]); + }, + ); + }); + }); - expect(controller.state).toStrictEqual({ + describe('NetworkController:stateChange', () => { + it('remove state from deleted networks', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; + + await withController( + { + options: { + disabled: true, + state: { marketData: { - [ChainId.mainnet]: { + [chainId]: { '0x0000000000000000000000000000000000000000': { - currency: 'ETH', - }, + currency: nativeCurrency, + price: 0.001, + } as unknown as MarketDataDetails, }, - }, - }); - }, - ); - }); - - it('does not update state if the price update fails', async () => { - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest - .fn() - .mockRejectedValue(new Error('Failed to fetch')), - }); - await withController( - { options: { tokenPricesService } }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - const tokenAddress = '0x0000000000000000000000000000000000000001'; - - const updateExchangeRates = await callUpdateExchangeRatesMethod({ - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddress, - decimals: 18, - symbol: 'TST', - aggregators: [], - }, - ], + '0x2': { + '0x0000000000000000000000000000000000000000': { + currency: nativeCurrency, + price: 0.001, + } as unknown as MarketDataDetails, }, }, - chainId: ChainId.mainnet, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: 'ETH', - selectedNetworkClientId: InfuraNetworkType.mainnet, - }); - - expect(updateExchangeRates).toBeUndefined(); - expect(controller.state.marketData).toStrictEqual({}); - }, - ); - }); - - it('fetches rates for all tokens in batches', async () => { - const chainId = ChainId.mainnet; - const ticker = NetworksTicker.mainnet; - const tokenAddresses = [...new Array(200).keys()] - .map(buildAddress) - .sort(); - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, - }); - const fetchTokenPricesSpy = jest.spyOn( - tokenPricesService, - 'fetchTokenPrices', - ); - const tokens = tokenAddresses.map((tokenAddress) => { - return buildToken({ address: tokenAddress }); - }); - await withController( - { - options: { - tokenPricesService, }, }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { + }, + async ({ controller, triggerNetworkStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); + + triggerNetworkStateChange( + { + ...getDefaultNetworkControllerState(), + networkConfigurationsByChainId: { [chainId]: { - [defaultSelectedAddress]: tokens.slice(0, 100), - // Include tokens from non selected addresses - '0x0000000000000000000000000000000000000123': - tokens.slice(100), - }, + chainId, + nativeCurrency, + } as unknown as NetworkConfiguration, }, - chainId, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: ticker, - selectedNetworkClientId: InfuraNetworkType.mainnet, - }); - - const numBatches = Math.ceil( - tokenAddresses.length / TOKEN_PRICES_BATCH_SIZE, - ); - expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(numBatches); - - for (let i = 1; i <= numBatches; i++) { - expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(i, { - chainId, - tokenAddresses: tokenAddresses.slice( - (i - 1) * TOKEN_PRICES_BATCH_SIZE, - i * TOKEN_PRICES_BATCH_SIZE, - ), - currency: ticker, - }); - } - }, - ); - }); - - it('updates all rates', async () => { - const tokenAddresses = [ - '0x0000000000000000000000000000000000000001', - '0x0000000000000000000000000000000000000002', - '0x0000000000000000000000000000000000000003', - ]; - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest.fn().mockResolvedValue({ - [tokenAddresses[0]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[0], - value: 0.001, - }, - [tokenAddresses[1]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[1], - value: 0.002, - }, - [tokenAddresses[2]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[2], - value: 0.003, }, - }), - }); - await withController( - { options: { tokenPricesService } }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - { - address: tokenAddresses[1], - decimals: 18, - symbol: 'TST2', - aggregators: [], - }, - ], - // Include tokens from non selected addresses - '0x0000000000000000000000000000000000000123': [ - { - address: tokenAddresses[2], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - ], - }, + [ + { + op: 'remove', + path: ['networkConfigurationsByChainId', chainId], }, - chainId: ChainId.mainnet, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: 'ETH', - selectedNetworkClientId: InfuraNetworkType.mainnet, - }); - - expect(controller.state).toMatchInlineSnapshot(` - Object { - "marketData": Object { - "0x1": Object { - "0x0000000000000000000000000000000000000001": Object { - "currency": "ETH", - "tokenAddress": "0x0000000000000000000000000000000000000001", - "value": 0.001, - }, - "0x0000000000000000000000000000000000000002": Object { - "currency": "ETH", - "tokenAddress": "0x0000000000000000000000000000000000000002", - "value": 0.002, - }, - "0x0000000000000000000000000000000000000003": Object { - "currency": "ETH", - "tokenAddress": "0x0000000000000000000000000000000000000003", - "value": 0.003, - }, - }, - }, - } - `); - }, - ); - }); + ], + ); - if (method === 'updateExchangeRatesByChainId') { - it('updates rates only for a non-selected chain', async () => { - const tokenAddresses = [ - '0x0000000000000000000000000000000000000001', - '0x0000000000000000000000000000000000000002', - ]; - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest.fn().mockResolvedValue({ - [tokenAddresses[0]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[0], - value: 0.001, - }, - [tokenAddresses[1]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[1], - value: 0.002, - }, - }), - }); - await withController( - { options: { tokenPricesService } }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - [toHex(2)]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - { - address: tokenAddresses[1], - decimals: 18, - symbol: 'TST2', - aggregators: [], - }, - ], - }, - }, - chainId: toHex(2), - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: 'ETH', - setChainAsCurrent: false, - }); - - expect(controller.state).toMatchInlineSnapshot(` - Object { - "marketData": Object { - "0x2": Object { - "0x0000000000000000000000000000000000000001": Object { - "currency": "ETH", - "tokenAddress": "0x0000000000000000000000000000000000000001", - "value": 0.001, - }, - "0x0000000000000000000000000000000000000002": Object { - "currency": "ETH", - "tokenAddress": "0x0000000000000000000000000000000000000002", - "value": 0.002, - }, - }, - }, - } - `); + jest.advanceTimersToNextTimer(); + await flushPromises(); + + expect(controller.state.marketData).toStrictEqual({ + '0x2': { + '0x0000000000000000000000000000000000000000': { + currency: nativeCurrency, + price: 0.001, + } as unknown as MarketDataDetails, }, - ); - }); - } - - it('updates exchange rates when native currency is not supported by the Price API', async () => { - const selectedNetworkClientId = 'AAAA-BBBB-CCCC-DDDD'; - const selectedNetworkClientConfiguration = - buildCustomNetworkClientConfiguration({ - chainId: toHex(137), - ticker: 'UNSUPPORTED', }); - const tokenAddresses = [ - '0x0000000000000000000000000000000000000001', - '0x0000000000000000000000000000000000000002', - ]; - const nativeTokenAddress = '0x0000000000000000000000000000000000001010'; - const nativeTokenPriceInUSD = 2; - const tokenPricesService = buildMockTokenPricesService({ - // @ts-expect-error - Simplified mock for testing with partial fields - fetchTokenPrices: async ({ tokenAddresses: addrs, currency }) => { - if (addrs.length === 0 && currency === 'usd') { - // Return native token price - return { - [nativeTokenAddress]: { - currency: 'usd', - tokenAddress: nativeTokenAddress, - price: nativeTokenPriceInUSD, - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: undefined, - allTimeLow: undefined, - circulatingSupply: 0, - dilutedMarketCap: undefined, - high1d: undefined, - low1d: undefined, - marketCap: undefined, - marketCapPercentChange1d: 0, - pricePercentChange14d: 0, - pricePercentChange1h: 0, - pricePercentChange1y: 0, - pricePercentChange200d: 0, - pricePercentChange30d: 0, - pricePercentChange7d: 0, - totalVolume: undefined, - }, - }; - } - // Return token prices in USD - return { - [tokenAddresses[0]]: { - currency: 'usd', - tokenAddress: tokenAddresses[0], - price: 0.001, - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: undefined, - allTimeLow: undefined, - circulatingSupply: 0, - dilutedMarketCap: undefined, - high1d: undefined, - low1d: undefined, - marketCap: undefined, - marketCapPercentChange1d: 0, - pricePercentChange14d: 0, - pricePercentChange1h: 0, - pricePercentChange1y: 0, - pricePercentChange200d: 0, - pricePercentChange30d: 0, - pricePercentChange7d: 0, - totalVolume: undefined, - }, - [tokenAddresses[1]]: { - currency: 'usd', - tokenAddress: tokenAddresses[1], - price: 0.002, - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: undefined, - allTimeLow: undefined, - circulatingSupply: 0, - dilutedMarketCap: undefined, - high1d: undefined, - low1d: undefined, - marketCap: undefined, - marketCapPercentChange1d: 0, - pricePercentChange14d: 0, - pricePercentChange1h: 0, - pricePercentChange1y: 0, - pricePercentChange200d: 0, - pricePercentChange30d: 0, - pricePercentChange7d: 0, - totalVolume: undefined, - }, - }; - }, - validateCurrencySupported(_currency: unknown): _currency is string { - return false; - }, - }); + }, + ); + }); + }); - await withController( - { - options: { tokenPricesService }, - mockNetworkClientConfigurationsByNetworkClientId: { - [selectedNetworkClientId]: selectedNetworkClientConfiguration, - }, - }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - [selectedNetworkClientConfiguration.chainId]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - { - address: tokenAddresses[1], - decimals: 18, - symbol: 'TST2', - aggregators: [], - }, - ], - }, - }, - chainId: selectedNetworkClientConfiguration.chainId, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: selectedNetworkClientConfiguration.ticker, - selectedNetworkClientId, - }); - - // token value in terms of matic should be (token value in eth) * (eth value in matic) - expect(controller.state).toMatchInlineSnapshot(` - Object { - "marketData": Object { - "0x89": Object { - "0x0000000000000000000000000000000000000001": Object { - "allTimeHigh": undefined, - "allTimeLow": undefined, - "circulatingSupply": 0, - "currency": "UNSUPPORTED", - "dilutedMarketCap": undefined, - "high1d": undefined, - "low1d": undefined, - "marketCap": undefined, - "marketCapPercentChange1d": 0, - "price": 0.0005, - "priceChange1d": 0, - "pricePercentChange14d": 0, - "pricePercentChange1d": 0, - "pricePercentChange1h": 0, - "pricePercentChange1y": 0, - "pricePercentChange200d": 0, - "pricePercentChange30d": 0, - "pricePercentChange7d": 0, - "tokenAddress": "0x0000000000000000000000000000000000000001", - "totalVolume": undefined, - }, - "0x0000000000000000000000000000000000000002": Object { - "allTimeHigh": undefined, - "allTimeLow": undefined, - "circulatingSupply": 0, - "currency": "UNSUPPORTED", - "dilutedMarketCap": undefined, - "high1d": undefined, - "low1d": undefined, - "marketCap": undefined, - "marketCapPercentChange1d": 0, - "price": 0.001, - "priceChange1d": 0, - "pricePercentChange14d": 0, - "pricePercentChange1d": 0, - "pricePercentChange1h": 0, - "pricePercentChange1y": 0, - "pricePercentChange200d": 0, - "pricePercentChange30d": 0, - "pricePercentChange7d": 0, - "tokenAddress": "0x0000000000000000000000000000000000000002", - "totalVolume": undefined, - }, - }, - }, - } - `); - }, - ); - }); + describe('enable', () => { + it('enables events', async () => { + jest.useFakeTimers(); - it('fetches rates for all tokens in batches when native currency is not supported by the Price API', async () => { - const selectedNetworkClientId = 'AAAA-BBBB-CCCC-DDDD'; - const selectedNetworkClientConfiguration = - buildCustomNetworkClientConfiguration({ - chainId: toHex(999), - ticker: 'UNSUPPORTED', - }); - const tokenAddresses = [...new Array(200).keys()] - .map(buildAddress) - .sort(); - // New implementation needs native token price in USD - // For chain 999 (0x3e7), native token address is 0x0000...0000 (ZERO_ADDRESS) - const nativeTokenAddress = '0x0000000000000000000000000000000000000000'; - const nativeTokenPriceInUSD = 2; - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: async ({ tokenAddresses: addrs, currency }) => { - // Handle native token price request - if (addrs.length === 0 && currency === 'usd') { - return { - [nativeTokenAddress]: { - tokenAddress: nativeTokenAddress, - currency: 'usd', - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: nativeTokenPriceInUSD, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, - }; - } - // Handle regular token prices - return fetchTokenPricesWithIncreasingPriceForEachToken({ - tokenAddresses: addrs, - currency, - }); - }, - validateCurrencySupported: ( - currency: unknown, - ): currency is string => { - return currency !== selectedNetworkClientConfiguration.ticker; + const chainId = '0x1'; + await withController( + { + options: { + disabled: true, }, - }); - const fetchTokenPricesSpy = jest.spyOn( - tokenPricesService, - 'fetchTokenPrices', - ); - const tokens = tokenAddresses.map((tokenAddress) => { - return buildToken({ address: tokenAddress }); - }); - await withController( - { - options: { - tokenPricesService, - }, - mockNetworkClientConfigurationsByNetworkClientId: { - [selectedNetworkClientId]: selectedNetworkClientConfiguration, - }, - mockNetworkState: { - networkConfigurationsByChainId: { - [selectedNetworkClientConfiguration.chainId]: { - nativeCurrency: selectedNetworkClientConfiguration.ticker, - chainId: selectedNetworkClientConfiguration.chainId, - name: 'UNSUPPORTED', - rpcEndpoints: [], - blockExplorerUrls: [], - defaultRpcEndpointIndex: 0, - }, + }, + async ({ controller, triggerTokensStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); + + controller.enable(); + + triggerTokensStateChange({ + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], }, - selectedNetworkClientId, }, - }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - [selectedNetworkClientConfiguration.chainId]: { - [defaultSelectedAddress]: tokens, - }, - }, - chainId: selectedNetworkClientConfiguration.chainId, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: selectedNetworkClientConfiguration.ticker, - selectedNetworkClientId, - }); - - const numBatches = Math.ceil( - tokenAddresses.length / TOKEN_PRICES_BATCH_SIZE, - ); - // New implementation calls fetchTokenPrices once for native token + numBatches for tokens - expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(numBatches + 1); - - // First call is for native token price - expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(1, { - chainId: selectedNetworkClientConfiguration.chainId, - tokenAddresses: [], - currency: 'usd', - }); - - // Subsequent calls are for token batches in USD - for (let i = 1; i <= numBatches; i++) { - expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(i + 1, { - chainId: selectedNetworkClientConfiguration.chainId, - tokenAddresses: tokenAddresses.slice( - (i - 1) * TOKEN_PRICES_BATCH_SIZE, - i * TOKEN_PRICES_BATCH_SIZE, - ), - currency: 'usd', - }); - } - }, - ); - }); - - it('sets rates to undefined when chain is not supported by the Price API', async () => { - const selectedNetworkClientId = 'AAAA-BBBB-CCCC-DDDD'; - const selectedNetworkClientConfiguration = - buildCustomNetworkClientConfiguration({ - chainId: toHex(999), - ticker: 'TST', + allDetectedTokens: {}, + allIgnoredTokens: {}, }); - const tokenAddresses = [ - '0x0000000000000000000000000000000000000001', - '0x0000000000000000000000000000000000000002', - ]; - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest.fn().mockResolvedValue({ - [tokenAddresses[0]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[0], - value: 0.001, - }, - [tokenAddresses[1]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[1], - value: 0.002, - }, - }), - validateChainIdSupported(_chainId: unknown): _chainId is Hex { - return false; - }, - }); - await withController( - { - options: { tokenPricesService }, - mockNetworkClientConfigurationsByNetworkClientId: { - [selectedNetworkClientId]: selectedNetworkClientConfiguration, - }, - }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - [toHex(999)]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - { - address: tokenAddresses[1], - decimals: 18, - symbol: 'TST2', - aggregators: [], - }, - ], - }, - }, - chainId: selectedNetworkClientConfiguration.chainId, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: selectedNetworkClientConfiguration.ticker, - selectedNetworkClientId, - }); - - expect(controller.state).toMatchInlineSnapshot(` - Object { - "marketData": Object { - "0x3e7": Object { - "0x0000000000000000000000000000000000000001": undefined, - "0x0000000000000000000000000000000000000002": undefined, - }, - }, - } - `); - }, - ); - }); - it('correctly calls the Price API with unqiue native token addresses (e.g. MATIC)', async () => { - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest.fn().mockResolvedValue({ - '0x0000000000000000000000000000000000001010': { - currency: 'MATIC', - tokenAddress: '0x0000000000000000000000000000000000001010', - value: 0.001, - }, - }), - }); + jest.advanceTimersToNextTimer(); + await flushPromises(); - await withController( - { - options: { tokenPricesService }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: '0x89', - }), + expect(controller.updateExchangeRates).toHaveBeenCalledWith([ + { + chainId, + nativeCurrency: 'ETH', }, - }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - '0x89': { - [defaultSelectedAddress]: [], - }, - }, - chainId: '0x89', - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: 'MATIC', - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); - - expect( - controller.state.marketData['0x89'][ - '0x0000000000000000000000000000000000001010' - ], - ).toBeDefined(); - }, - ); - }); + ]); + }, + ); + }); + }); - it('only updates rates once when called twice', async () => { - const tokenAddresses = [ - '0x0000000000000000000000000000000000000001', - '0x0000000000000000000000000000000000000002', - ]; - const fetchTokenPricesMock = jest.fn().mockResolvedValue({ - [tokenAddresses[0]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[0], - value: 0.001, - }, - [tokenAddresses[1]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[1], - value: 0.002, - }, - }); - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: fetchTokenPricesMock, - }); - await withController( - { options: { tokenPricesService } }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - const updateExchangeRates = async () => - await callUpdateExchangeRatesMethod({ - allTokens: { - [toHex(1)]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - { - address: tokenAddresses[1], - decimals: 18, - symbol: 'TST2', - aggregators: [], - }, - ], - }, - }, - chainId: ChainId.mainnet, - selectedNetworkClientId: InfuraNetworkType.mainnet, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: 'ETH', - }); - - await Promise.all([updateExchangeRates(), updateExchangeRates()]); - - expect(fetchTokenPricesMock).toHaveBeenCalledTimes(1); - - expect(controller.state).toMatchInlineSnapshot(` - Object { - "marketData": Object { - "0x1": Object { - "0x0000000000000000000000000000000000000001": Object { - "currency": "ETH", - "tokenAddress": "0x0000000000000000000000000000000000000001", - "value": 0.001, - }, - "0x0000000000000000000000000000000000000002": Object { - "currency": "ETH", - "tokenAddress": "0x0000000000000000000000000000000000000002", - "value": 0.002, - }, - }, - }, - } - `); - }, - ); - }); + describe('disable', () => { + it('disables events', async () => { + jest.useFakeTimers(); - it('will update rates twice if detected tokens increased during second call', async () => { - const tokenAddresses = [ - '0x0000000000000000000000000000000000000001', - '0x0000000000000000000000000000000000000002', - ]; - const fetchTokenPricesMock = jest.fn().mockResolvedValue({ - [tokenAddresses[0]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[0], - value: 0.001, - }, - [tokenAddresses[1]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[1], - value: 0.002, + const chainId = '0x1'; + await withController( + { + options: { + disabled: false, }, - }); - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: fetchTokenPricesMock, - }); - await withController( - { options: { tokenPricesService } }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - const request1Payload = [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - ]; - const request2Payload = [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - { - address: tokenAddresses[1], - decimals: 18, - symbol: 'TST2', - aggregators: [], - }, - ]; - const updateExchangeRates = async ( - tokens: typeof request1Payload | typeof request2Payload, - ) => - await callUpdateExchangeRatesMethod({ - allTokens: { - [toHex(1)]: { - [defaultSelectedAddress]: tokens, + }, + async ({ controller, triggerTokensStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); + + controller.disable(); + + triggerTokensStateChange({ + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', }, - }, - chainId: ChainId.mainnet, - selectedNetworkClientId: InfuraNetworkType.mainnet, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: 'ETH', - }); - - await Promise.all([ - updateExchangeRates(request1Payload), - updateExchangeRates(request2Payload), - ]); - - expect(fetchTokenPricesMock).toHaveBeenCalledTimes(2); - expect(fetchTokenPricesMock).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - tokenAddresses: [tokenAddresses[0]], - }), - ); - expect(fetchTokenPricesMock).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - tokenAddresses: [tokenAddresses[0], tokenAddresses[1]], - }), - ); - }, - ); - }); + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }); + + jest.advanceTimersToNextTimer(); + await flushPromises(); + + expect(controller.updateExchangeRates).not.toHaveBeenCalled(); + }, + ); }); }); @@ -2939,12 +1155,10 @@ describe('TokenRatesController', () => { */ type WithControllerCallback = ({ controller, - triggerSelectedAccountChange, triggerTokensStateChange, triggerNetworkStateChange, }: { controller: TokenRatesController; - triggerSelectedAccountChange: (state: InternalAccount) => void; triggerTokensStateChange: (state: TokensControllerState) => void; triggerNetworkStateChange: (state: NetworkState, patches?: Patch[]) => void; }) => Promise | ReturnValue; @@ -2976,12 +1190,7 @@ async function withController( ...args: WithControllerArgs ): Promise { const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; - const { - options, - mockNetworkClientConfigurationsByNetworkClientId, - mockTokensControllerState, - mockNetworkState, - } = rest; + const { options, mockTokensControllerState, mockNetworkState } = rest; const messenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, }); @@ -2995,14 +1204,6 @@ async function withController( }), ); - const getNetworkClientById = buildMockGetNetworkClientById( - mockNetworkClientConfigurationsByNetworkClientId, - ); - messenger.registerActionHandler( - 'NetworkController:getNetworkClientById', - getNetworkClientById, - ); - const networkStateMock = jest.fn(); messenger.registerActionHandler( 'NetworkController:getState', @@ -3012,18 +1213,6 @@ async function withController( }), ); - const mockGetSelectedAccount = jest.fn(); - messenger.registerActionHandler( - 'AccountsController:getSelectedAccount', - mockGetSelectedAccount.mockReturnValue(defaultSelectedAccount), - ); - - const mockGetAccount = jest.fn(); - messenger.registerActionHandler( - 'AccountsController:getAccount', - mockGetAccount.mockReturnValue(defaultSelectedAccount), - ); - const controller = new TokenRatesController({ tokenPricesService: buildMockTokenPricesService(), messenger: buildTokenRatesControllerMessenger(messenger), @@ -3032,13 +1221,6 @@ async function withController( try { return await fn({ controller, - triggerSelectedAccountChange: (account: InternalAccount) => { - messenger.publish( - 'AccountsController:selectedEvmAccountChange', - account, - ); - }, - triggerTokensStateChange: (state: TokensControllerState) => { messenger.publish('TokensController:stateChange', state, []); }, @@ -3050,106 +1232,10 @@ async function withController( }, }); } finally { - controller.stop(); controller.stopAllPolling(); } } -/** - * Call an "update exchange rates" method with the given parameters. - * - * The TokenRatesController has two methods for updating exchange rates: - * `updateExchangeRates` and `updateExchangeRatesByChainId`. They are the same - * except in how the inputs are specified. `updateExchangeRates` gets the - * inputs from controller configuration, whereas `updateExchangeRatesByChainId` - * accepts the inputs as parameters. - * - * This helper function normalizes between these two functions, so that we can - * test them the same way. - * - * @param args - The arguments. - * @param args.allTokens - The `allTokens` state (from the TokensController) - * @param args.chainId - The chain ID of the chain we want to update the - * exchange rates for. - * @param args.controller - The controller to call the method with. - * @param args.triggerTokensStateChange - Controller event handlers, used to - * update controller configuration. - * @param args.triggerNetworkStateChange - Controller event handlers, used to - * update controller configuration. - * @param args.method - The "update exchange rates" method to call. - * @param args.nativeCurrency - The symbol for the native currency of the - * network we're getting updated exchange rates for. - * @param args.setChainAsCurrent - When calling `updateExchangeRatesByChainId`, - * this determines whether to set the chain as the globally selected chain. - * @param args.selectedNetworkClientId - The network client ID to use if - * `setChainAsCurrent` is true. - */ -async function callUpdateExchangeRatesMethod({ - allTokens, - chainId, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency, - selectedNetworkClientId, - setChainAsCurrent = true, -}: { - allTokens: TokensControllerState['allTokens']; - chainId: Hex; - controller: TokenRatesController; - triggerTokensStateChange: (state: TokensControllerState) => void; - triggerNetworkStateChange: (state: NetworkState) => void; - method: 'updateExchangeRates' | 'updateExchangeRatesByChainId'; - nativeCurrency: string; - selectedNetworkClientId?: NetworkClientId; - setChainAsCurrent?: boolean; -}) { - if (method === 'updateExchangeRates' && !setChainAsCurrent) { - throw new Error( - 'The "setChainAsCurrent" flag cannot be enabled when calling the "updateExchangeRates" method', - ); - } - - triggerTokensStateChange({ - ...getDefaultTokensState(), - allDetectedTokens: {}, - allTokens, - }); - - if (setChainAsCurrent) { - assert( - selectedNetworkClientId, - 'The "selectedNetworkClientId" option must be given if the "setChainAsCurrent" flag is also given', - ); - - // We're using controller events here instead of calling `configure` - // because `configure` does not update internal controller state correctly. - // As with many BaseControllerV1-based controllers, runtime config - // modification is allowed by the API but not supported in practice. - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId, - }); - } - - if (method === 'updateExchangeRates') { - await controller.updateExchangeRates([ - { - chainId, - nativeCurrency, - }, - ]); - } else { - await controller.updateExchangeRatesByChainId([ - { - chainId, - nativeCurrency, - }, - ]); - } -} - /** * Builds a mock token prices service. * @@ -3162,7 +1248,7 @@ function buildMockTokenPricesService( ): AbstractTokenPricesService { return { async fetchTokenPrices() { - return {}; + return []; }, async fetchExchangeRates() { return {}; @@ -3182,50 +1268,44 @@ function buildMockTokenPricesService( * price of each given token is incremented by one. * * @param args - The arguments to this function. - * @param args.tokenAddresses - The token addresses. + * @param args.assets - The token addresses and chainIds. * @param args.currency - The currency. * @returns The token prices. */ async function fetchTokenPricesWithIncreasingPriceForEachToken< - TokenAddress extends Hex, Currency extends string, >({ - tokenAddresses, + assets, currency, }: { - tokenAddresses: TokenAddress[]; + assets: { tokenAddress: Hex; chainId: Hex }[]; currency: Currency; -}) { - return tokenAddresses.reduce< - Partial> - >((obj, tokenAddress, i) => { - const tokenPrice: TokenPrice = { - tokenAddress, - currency, - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: (i + 1) / 1000, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }; - return { - ...obj, - [tokenAddress]: tokenPrice, - }; - }, {}) as TokenPricesByTokenAddress; +}): Promise[]> { + return assets.map(({ tokenAddress, chainId }, i) => ({ + tokenAddress, + chainId, + assetId: + `${KnownCaipNamespace.Eip155}:1/${tokenAddress === ZERO_ADDRESS ? 'slip44:60' : `erc20:${tokenAddress.toLowerCase()}`}` as CaipAssetType, + currency, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + price: (i + 1) / 1000, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + })); } /** diff --git a/packages/assets-controllers/src/TokenRatesController.ts b/packages/assets-controllers/src/TokenRatesController.ts index a9417e39282..b16cde9aab7 100644 --- a/packages/assets-controllers/src/TokenRatesController.ts +++ b/packages/assets-controllers/src/TokenRatesController.ts @@ -1,25 +1,16 @@ -import type { - AccountsControllerGetAccountAction, - AccountsControllerGetSelectedAccountAction, - AccountsControllerSelectedEvmAccountChangeEvent, -} from '@metamask/accounts-controller'; import type { ControllerGetStateAction, ControllerStateChangeEvent, StateMetadata, } from '@metamask/base-controller'; -import { - safelyExecute, - toChecksumHexAddress, -} from '@metamask/controller-utils'; +import { toChecksumHexAddress } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import type { - NetworkControllerGetNetworkClientByIdAction, NetworkControllerGetStateAction, NetworkControllerStateChangeEvent, } from '@metamask/network-controller'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; -import { createDeferredPromise, type Hex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { isEqual } from 'lodash'; import { reduceInBatchesSerially, TOKEN_PRICES_BATCH_SIZE } from './assetsUtil'; @@ -90,28 +81,24 @@ export type MarketDataDetails = { */ export type ContractMarketData = Record; -enum PollState { - Active = 'Active', - Inactive = 'Inactive', -} +type ChainIdAndNativeCurrency = { + chainId: Hex; + nativeCurrency: string; +}; /** * The external actions available to the {@link TokenRatesController}. */ export type AllowedActions = | TokensControllerGetStateAction - | NetworkControllerGetNetworkClientByIdAction - | NetworkControllerGetStateAction - | AccountsControllerGetAccountAction - | AccountsControllerGetSelectedAccountAction; + | NetworkControllerGetStateAction; /** * The external events available to the {@link TokenRatesController}. */ export type AllowedEvents = | TokensControllerStateChangeEvent - | NetworkControllerStateChangeEvent - | AccountsControllerSelectedEvmAccountChangeEvent; + | NetworkControllerStateChangeEvent; /** * The name of the {@link TokenRatesController}. @@ -199,18 +186,10 @@ export class TokenRatesController extends StaticIntervalPollingController { - #handle?: ReturnType; - - #pollState = PollState.Inactive; - readonly #tokenPricesService: AbstractTokenPricesService; - #inProcessExchangeRateUpdates: Record<`${Hex}:${string}`, Promise> = {}; - #disabled: boolean; - readonly #interval: number; - #allTokens: TokensControllerState['allTokens']; #allDetectedTokens: TokensControllerState['allDetectedTokens']; @@ -248,7 +227,6 @@ export class TokenRatesController extends StaticIntervalPollingController { return { allTokens, allDetectedTokens }; @@ -320,25 +298,7 @@ export class TokenRatesController extends StaticIntervalPollingController { - const chainIdAndNativeCurrency: { - chainId: Hex; - nativeCurrency: string; - }[] = Object.values(networkConfigurationsByChainId).map( - ({ chainId, nativeCurrency }) => { - return { - chainId: chainId as Hex, - nativeCurrency, - }; - }, - ); - - if (this.#pollState === PollState.Active) { - await this.updateExchangeRates(chainIdAndNativeCurrency); - } - + (_state, patches) => { // Remove state for deleted networks for (const patch of patches) { if ( @@ -370,7 +330,13 @@ export class TokenRatesController extends StaticIntervalPollingController - this.updateExchangeRates([{ chainId, nativeCurrency }]), - ); - - // Poll using recursive `setTimeout` instead of `setInterval` so that - // requests don't stack if they take longer than the polling interval - this.#handle = setTimeout(() => { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.#poll(chainId, nativeCurrency); - }, this.#interval); - } - /** * Updates exchange rates for all tokens. * * @param chainIdAndNativeCurrency - The chain ID and native currency. */ async updateExchangeRates( - chainIdAndNativeCurrency: { - chainId: Hex; - nativeCurrency: string; - }[], - ) { - await this.updateExchangeRatesByChainId(chainIdAndNativeCurrency); - } - - /** - * Updates exchange rates for all tokens. - * - * @param chainIds - The chain IDs. - * @returns A promise that resolves when all chain updates complete. - */ - /** - * Updates exchange rates for all tokens. - * - * @param chainIdAndNativeCurrency - The chain ID and native currency. - */ - async updateExchangeRatesByChainId( - chainIdAndNativeCurrency: { - chainId: Hex; - nativeCurrency: string; - }[], + chainIdAndNativeCurrency: ChainIdAndNativeCurrency[], ): Promise { if (this.#disabled) { return; } - // Create a promise for each chainId to fetch exchange rates. - const updatePromises = chainIdAndNativeCurrency.map( - async ({ chainId, nativeCurrency }) => { - const tokenAddresses = this.#getTokenAddresses(chainId); - // Build a unique key based on chainId, nativeCurrency, and the number of token addresses. - const updateKey: `${Hex}:${string}` = `${chainId}:${nativeCurrency}:${tokenAddresses.length}`; - - if (updateKey in this.#inProcessExchangeRateUpdates) { - // Await any ongoing update to avoid redundant work. - await this.#inProcessExchangeRateUpdates[updateKey]; - return null; + const marketData: Record> = {}; + const assetsByNativeCurrency: Record< + string, + { + chainId: Hex; + tokenAddress: Hex; + }[] + > = {}; + const unsupportedAssetsByNativeCurrency: Record< + string, + { + chainId: Hex; + tokenAddress: Hex; + }[] + > = {}; + for (const { chainId, nativeCurrency } of chainIdAndNativeCurrency) { + if (this.#tokenPricesService.validateChainIdSupported(chainId)) { + for (const tokenAddress of this.#getTokenAddresses(chainId)) { + if ( + this.#tokenPricesService.validateCurrencySupported(nativeCurrency) + ) { + (assetsByNativeCurrency[nativeCurrency] ??= []).push({ + chainId, + tokenAddress, + }); + } else { + (unsupportedAssetsByNativeCurrency[nativeCurrency] ??= []).push({ + chainId, + tokenAddress, + }); + } } + } + } - // Create a deferred promise to track this update. - const { - promise: inProgressUpdate, - resolve: updateSucceeded, - reject: updateFailed, - } = createDeferredPromise({ suppressUnhandledRejection: true }); - this.#inProcessExchangeRateUpdates[updateKey] = inProgressUpdate; - - try { - const contractInformations = await this.#fetchAndMapExchangeRates({ - tokenAddresses, - chainId, + const promises = [ + ...Object.entries(assetsByNativeCurrency).map( + ([nativeCurrency, assets]) => + this.#fetchAndMapExchangeRatesForSupportedNativeCurrency( + assets, nativeCurrency, - }); + marketData, + ), + ), + ...Object.entries(unsupportedAssetsByNativeCurrency).map( + ([nativeCurrency, assets]) => + this.#fetchAndMapExchangeRatesForUnsupportedNativeCurrency( + assets, + nativeCurrency, + marketData, + ), + ), + ]; - // Each promise returns an object with the market data for the chain. - const marketData = { - [chainId]: { - ...(contractInformations ?? {}), - }, - }; - - updateSucceeded(); - return marketData; - } catch (error: unknown) { - updateFailed(error); - throw error; - } finally { - // Cleanup the tracking for this update. - delete this.#inProcessExchangeRateUpdates[updateKey]; - } - }, - ); + await Promise.allSettled(promises); - // Wait for all update promises to settle. - const results = await Promise.allSettled(updatePromises); + const chainIds = new Set( + Object.values(chainIdAndNativeCurrency).map((chain) => chain.chainId), + ); - // Merge all successful market data updates into one object. - const combinedMarketData = results.reduce((acc, result) => { - if (result.status === 'fulfilled' && result.value) { - acc = { ...acc, ...result.value }; + for (const chainId of chainIds) { + if (!marketData[chainId]) { + marketData[chainId] = {}; } - return acc; - }, {}); + } - // Call this.update only once with the combined market data to reduce the number of state changes and re-renders - if (Object.keys(combinedMarketData).length > 0) { + if (Object.keys(marketData).length > 0) { this.update((state) => { state.marketData = { ...state.marketData, - ...combinedMarketData, + ...marketData, }; }); } } - /** - * Uses the token prices service to retrieve exchange rates for tokens in a - * particular currency. - * - * If the price API does not support the given chain ID, returns an empty - * object. - * - * If the price API does not support the given currency, retrieves exchange - * rates in a known currency instead, then converts those rates using the - * exchange rate between the known currency and desired currency. - * - * @param args - The arguments to this function. - * @param args.tokenAddresses - Addresses for tokens. - * @param args.chainId - The EIP-155 ID of the chain where the tokens live. - * @param args.nativeCurrency - The native currency in which to request - * exchange rates. - * @returns A map from token address to its exchange rate in the native - * currency, or an empty map if no exchange rates can be obtained for the - * chain ID. - */ - async #fetchAndMapExchangeRates({ - tokenAddresses, - chainId, - nativeCurrency, - }: { - tokenAddresses: Hex[]; - chainId: Hex; - nativeCurrency: string; - }): Promise { - if (!this.#tokenPricesService.validateChainIdSupported(chainId)) { - return tokenAddresses.reduce((obj, tokenAddress) => { - obj = { - ...obj, - [tokenAddress]: undefined, - }; - - return obj; - }, {}); - } + async #fetchAndMapExchangeRatesForSupportedNativeCurrency( + assets: { + chainId: Hex; + tokenAddress: Hex; + }[], + currency: string, + marketData: Record> = {}, + ) { + return await reduceInBatchesSerially< + { chainId: Hex; tokenAddress: Hex }, + Record> + >({ + values: assets, + batchSize: TOKEN_PRICES_BATCH_SIZE, + eachBatch: async (partialMarketData, assetsBatch) => { + const batchMarketData = await this.#tokenPricesService.fetchTokenPrices( + { + assets: assetsBatch, + currency, + }, + ); - if (this.#tokenPricesService.validateCurrencySupported(nativeCurrency)) { - return await this.#fetchAndMapExchangeRatesForSupportedNativeCurrency({ - tokenAddresses, - chainId, - nativeCurrency, - }); - } + for (const tokenPrice of batchMarketData) { + (partialMarketData[tokenPrice.chainId] ??= {})[ + tokenPrice.tokenAddress + ] = tokenPrice; + } - return await this.#fetchAndMapExchangeRatesForUnsupportedNativeCurrency({ - chainId, - tokenAddresses, - nativeCurrency, + return partialMarketData; + }, + initialResult: marketData, }); } + async #fetchAndMapExchangeRatesForUnsupportedNativeCurrency( + assets: { + chainId: Hex; + tokenAddress: Hex; + }[], + currency: string, + marketData: Record>, + ) { + // Step -1: Then fetch all tracked tokens priced in USD + const marketDataInUSD = + await this.#fetchAndMapExchangeRatesForSupportedNativeCurrency( + assets, + 'usd', // Fallback currency when the native currency is not supported + ); + + // Formula: price_in_native = token_usd / native_usd + const convertUSDToNative = ( + valueInUSD: number, + nativeTokenPriceInUSD: number, + ) => valueInUSD / nativeTokenPriceInUSD; + + // Step -2: Convert USD prices to native currency + for (const [chainId, marketDataByTokenAddress] of Object.entries( + marketDataInUSD, + ) as [Hex, Record][]) { + const nativeTokenPriceInUSD = + marketDataByTokenAddress[getNativeTokenAddress(chainId)]?.price; + + // Return here if it's null, undefined or 0 + if (!nativeTokenPriceInUSD) { + continue; + } + + for (const [tokenAddress, tokenData] of Object.entries( + marketDataByTokenAddress, + ) as [Hex, MarketDataDetails][]) { + (marketData[chainId] ??= {})[tokenAddress] = { + ...tokenData, + currency, + price: convertUSDToNative(tokenData.price, nativeTokenPriceInUSD), + marketCap: convertUSDToNative( + tokenData.marketCap, + nativeTokenPriceInUSD, + ), + allTimeHigh: convertUSDToNative( + tokenData.allTimeHigh, + nativeTokenPriceInUSD, + ), + allTimeLow: convertUSDToNative( + tokenData.allTimeLow, + nativeTokenPriceInUSD, + ), + totalVolume: convertUSDToNative( + tokenData.totalVolume, + nativeTokenPriceInUSD, + ), + high1d: convertUSDToNative(tokenData.high1d, nativeTokenPriceInUSD), + low1d: convertUSDToNative(tokenData.low1d, nativeTokenPriceInUSD), + dilutedMarketCap: convertUSDToNative( + tokenData.dilutedMarketCap, + nativeTokenPriceInUSD, + ), + }; + } + } + } + /** * Updates token rates for the given networkClientId * @@ -637,163 +584,7 @@ export class TokenRatesController extends StaticIntervalPollingController { - let contractNativeInformations; - const tokenPricesByTokenAddress = await reduceInBatchesSerially< - Hex, - Awaited> - >({ - values: [...tokenAddresses].sort(), - batchSize: TOKEN_PRICES_BATCH_SIZE, - eachBatch: async (allTokenPricesByTokenAddress, batch) => { - const tokenPricesByTokenAddressForBatch = - await this.#tokenPricesService.fetchTokenPrices({ - tokenAddresses: batch, - chainId, - currency: nativeCurrency, - }); - - return { - ...allTokenPricesByTokenAddress, - ...tokenPricesByTokenAddressForBatch, - }; - }, - initialResult: {}, - }); - contractNativeInformations = tokenPricesByTokenAddress; - - // fetch for native token - if (tokenAddresses.length === 0) { - const contractNativeInformationsNative = - await this.#tokenPricesService.fetchTokenPrices({ - tokenAddresses: [], - chainId, - currency: nativeCurrency, - }); - - contractNativeInformations = { - [getNativeTokenAddress(chainId)]: { - currency: nativeCurrency, - ...contractNativeInformationsNative[getNativeTokenAddress(chainId)], - }, - }; - } - return Object.entries(contractNativeInformations).reduce( - (obj, [tokenAddress, token]) => { - obj = { - ...obj, - [tokenAddress]: { ...token }, - }; - - return obj; - }, - {}, - ); - } - - /** - * If the price API does not support a given native currency, then we need to - * convert it to a fallback currency and feed that currency into the price - * API, then convert the prices to our desired native currency. - * - * @param args - The arguments to this function. - * @param args.chainId - The chain id to fetch prices for. - * @param args.tokenAddresses - Addresses for tokens. - * @param args.nativeCurrency - The native currency in which to request - * prices. - * @returns A map of the token addresses (as checksums) to their prices in the - * native currency. - */ - async #fetchAndMapExchangeRatesForUnsupportedNativeCurrency({ - chainId, - tokenAddresses, - nativeCurrency, - }: { - chainId: Hex; - tokenAddresses: Hex[]; - nativeCurrency: string; - }): Promise { - const nativeTokenAddress = getNativeTokenAddress(chainId); - - // Step -1: First fetch native token priced in USD - const nativeTokenPriceMap = - await this.#fetchAndMapExchangeRatesForSupportedNativeCurrency({ - tokenAddresses: [] as Hex[], // special-case: returns only native token - chainId, - nativeCurrency: 'usd', - }); - - // Step -2: Then fetch all tracked tokens priced in USD - const tokenPricesInUSD = - await this.#fetchAndMapExchangeRatesForSupportedNativeCurrency({ - tokenAddresses, - chainId, - nativeCurrency: 'usd', - }); - - const nativeTokenInfo = nativeTokenPriceMap[nativeTokenAddress]; - const nativeTokenPriceInUSD = nativeTokenInfo?.price; - - if (!nativeTokenPriceInUSD || nativeTokenPriceInUSD === 0) { - // If we can't price the native token in the fallback currency, - // we can't safely convert; return empty so callers know there is no data. - return {}; - } - - // Step -3: Convert USD prices to native currency - // Formula: price_in_native = token_usd / native_usd - const convertUSDToNative = (valueInUSD: number | undefined) => - valueInUSD !== undefined && valueInUSD !== null - ? valueInUSD / nativeTokenPriceInUSD - : undefined; - - // Step -4 & -5: Apply conversion to all token fields and return - const tokenPricesInNative = Object.entries(tokenPricesInUSD).reduce( - (acc, [tokenAddress, tokenData]) => { - acc = { - ...acc, - [tokenAddress]: { - ...tokenData, - currency: nativeCurrency, - price: convertUSDToNative(tokenData.price), - marketCap: convertUSDToNative(tokenData.marketCap), - allTimeHigh: convertUSDToNative(tokenData.allTimeHigh), - allTimeLow: convertUSDToNative(tokenData.allTimeLow), - totalVolume: convertUSDToNative(tokenData.totalVolume), - high1d: convertUSDToNative(tokenData.high1d), - low1d: convertUSDToNative(tokenData.low1d), - dilutedMarketCap: convertUSDToNative(tokenData.dilutedMarketCap), - }, - }; - return acc; - }, - {} as ContractMarketData, - ); - - return tokenPricesInNative; + await this.updateExchangeRates(chainIdAndNativeCurrency); } /** diff --git a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts index 3a0e8d5bbd0..f72c8be26a4 100644 --- a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts +++ b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts @@ -1,11 +1,10 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import { ChainId } from '@metamask/controller-utils'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { Hex } from '@metamask/utils'; import assert from 'assert'; @@ -16,15 +15,16 @@ import { TokenSearchDiscoveryDataController, controllerName, MAX_TOKEN_DISPLAY_DATA_LENGTH, - type TokenSearchDiscoveryDataControllerMessenger, - type TokenSearchDiscoveryDataControllerState, +} from './TokenSearchDiscoveryDataController'; +import type { + TokenSearchDiscoveryDataControllerMessenger, + TokenSearchDiscoveryDataControllerState, } from './TokenSearchDiscoveryDataController'; import type { NotFoundTokenDisplayData, FoundTokenDisplayData } from './types'; import { advanceTime } from '../../../../tests/helpers'; import type { AbstractTokenPricesService, - TokenPrice, - TokenPricesByTokenAddress, + EvmAssetWithMarketData, } from '../token-prices-service/abstract-token-prices-service'; import { fetchTokenMetadata } from '../token-service'; import type { Token } from '../TokenRatesController'; @@ -79,10 +79,11 @@ function buildFoundTokenDisplayData( name: 'Test Token', }; - const priceData: TokenPrice = { + const priceData: EvmAssetWithMarketData = { price: 10.5, currency: 'USD', tokenAddress: tokenAddress as Hex, + chainId: '0x1', allTimeHigh: 20, allTimeLow: 5, circulatingSupply: 1000000, @@ -151,7 +152,7 @@ function buildMockTokenPricesService( return {}; }, async fetchTokenPrices() { - return {}; + return []; }, validateChainIdSupported(_chainId: unknown): _chainId is Hex { return true; @@ -492,10 +493,11 @@ describe('TokenSearchDiscoveryDataController', () => { Promise.resolve(tokenMetadata), ); - const mockPriceData: TokenPrice = { + const mockPriceData: EvmAssetWithMarketData = { price: 10.5, currency: 'USD', tokenAddress: tokenAddress as Hex, + chainId: '0x1', allTimeHigh: 20, allTimeLow: 5, circulatingSupply: 1000000, @@ -516,9 +518,7 @@ describe('TokenSearchDiscoveryDataController', () => { }; const mockTokenPricesService = { - fetchTokenPrices: jest.fn().mockResolvedValue({ - [tokenAddress as Hex]: mockPriceData, - }), + fetchTokenPrices: jest.fn().mockResolvedValue([mockPriceData]), }; await withController( @@ -643,12 +643,13 @@ describe('TokenSearchDiscoveryDataController', () => { currency, }: { currency: string; - }): Promise> { + }): Promise[]> { const basePrice: Omit< - TokenPrice, + EvmAssetWithMarketData, 'price' | 'currency' > = { tokenAddress: tokenAddress as Hex, + chainId: '0x1', allTimeHigh: 20, allTimeLow: 5, circulatingSupply: 1000000, @@ -668,13 +669,13 @@ describe('TokenSearchDiscoveryDataController', () => { totalVolume: 500000, }; - return { - [tokenAddress as Hex]: { + return [ + { ...basePrice, price: currency === 'USD' ? 10.5 : 9.5, currency, }, - }; + ]; }, }; @@ -713,10 +714,11 @@ describe('TokenSearchDiscoveryDataController', () => { decimals: 18, }); - const mockTokenPrice: TokenPrice = { + const mockTokenPrice: EvmAssetWithMarketData = { price: 10.5, currency: 'USD', tokenAddress: tokenAddress as Hex, + chainId: '0x1', allTimeHigh: 20, allTimeLow: 5, circulatingSupply: 1000000, diff --git a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.ts b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.ts index 489a58d29eb..b62ac16f47a 100644 --- a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.ts +++ b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.ts @@ -1,8 +1,8 @@ -import { - BaseController, - type StateMetadata, - type ControllerGetStateAction, - type ControllerStateChangeEvent, +import { BaseController } from '@metamask/base-controller'; +import type { + StateMetadata, + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import type { Hex } from '@metamask/utils'; @@ -11,7 +11,6 @@ import type { TokenDisplayData } from './types'; import { formatIconUrlWithProxy } from '../assetsUtil'; import type { GetCurrencyRateState } from '../CurrencyRateController'; import type { AbstractTokenPricesService } from '../token-prices-service'; -import type { TokenPrice } from '../token-prices-service/abstract-token-prices-service'; import { fetchTokenMetadata, TOKEN_METADATA_NO_SUPPORT_ERROR, @@ -172,22 +171,18 @@ export class TokenSearchDiscoveryDataController extends BaseController< this.#fetchSwapsTokensThresholdMs = fetchSwapsTokensThresholdMs; } - async #fetchPriceData( - chainId: Hex, - address: string, - ): Promise | null> { + async #fetchPriceData(chainId: Hex, address: string) { const { currentCurrency } = this.messenger.call( 'CurrencyRateController:getState', ); try { const pricesData = await this.#tokenPricesService.fetchTokenPrices({ - chainId, - tokenAddresses: [address as Hex], + assets: [{ chainId, tokenAddress: address as Hex }], currency: currentCurrency, }); - return pricesData[address as Hex] ?? null; + return pricesData[0] ?? null; } catch (error) { console.error(error); return null; diff --git a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/types.ts b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/types.ts index 7f092b58bbe..26b482ef141 100644 --- a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/types.ts +++ b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/types.ts @@ -1,6 +1,6 @@ import type { Hex } from '@metamask/utils'; -import type { TokenPrice } from '../token-prices-service/abstract-token-prices-service'; +import type { EvmAssetWithMarketData } from '../token-prices-service/abstract-token-prices-service'; import type { Token } from '../TokenRatesController'; export type NotFoundTokenDisplayData = { @@ -16,7 +16,7 @@ export type FoundTokenDisplayData = { address: string; currency: string; token: Token; - price: TokenPrice | null; + price: EvmAssetWithMarketData | null; }; export type TokenDisplayData = NotFoundTokenDisplayData | FoundTokenDisplayData; diff --git a/packages/assets-controllers/src/TokensController.test.ts b/packages/assets-controllers/src/TokensController.test.ts index 538719c8382..5b94989f1a1 100644 --- a/packages/assets-controllers/src/TokensController.test.ts +++ b/packages/assets-controllers/src/TokensController.test.ts @@ -1,8 +1,8 @@ import { Contract } from '@ethersproject/contracts'; -import type { ApprovalControllerMessenger } from '@metamask/approval-controller'; -import { - ApprovalController, - type ApprovalControllerState, +import { ApprovalController } from '@metamask/approval-controller'; +import type { + ApprovalControllerMessenger, + ApprovalControllerState, } from '@metamask/approval-controller'; import { deriveStateFromMetadata } from '@metamask/base-controller'; import contractMaps from '@metamask/contract-metadata'; @@ -14,12 +14,11 @@ import { InfuraNetworkType, } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MockAnyNamespace, - type MessengerActions, - type MessengerEvents, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, } from '@metamask/messenger'; import type { NetworkClientConfiguration, diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index cafd25dc26b..58606100bf3 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -38,7 +38,8 @@ import type { Provider, } from '@metamask/network-controller'; import { rpcErrors } from '@metamask/rpc-errors'; -import { isStrictHexString, type Hex } from '@metamask/utils'; +import { isStrictHexString } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { Mutex } from 'async-mutex'; import type { Patch } from 'immer'; import { cloneDeep } from 'lodash'; @@ -518,13 +519,10 @@ export class TokensController extends BaseController< ...(allTokens[interactingChainId]?.[this.#getSelectedAccount().address] || []), ...tokensToImport, - ].reduce( - (output, token) => { - output[toChecksumHexAddress(token.address)] = token; - return output; - }, - {} as { [address: string]: Token }, - ); + ].reduce<{ [address: string]: Token }>((output, token) => { + output[toChecksumHexAddress(token.address)] = token; + return output; + }, {}); try { tokensToImport.forEach((tokenToAdd) => { const { address, symbol, decimals, image, aggregators, name } = diff --git a/packages/assets-controllers/src/__fixtures__/account-api-v4-mocks.ts b/packages/assets-controllers/src/__fixtures__/account-api-v4-mocks.ts new file mode 100644 index 00000000000..a0e7030b24c --- /dev/null +++ b/packages/assets-controllers/src/__fixtures__/account-api-v4-mocks.ts @@ -0,0 +1,65 @@ +import type { Hex } from '@metamask/utils'; +import nock from 'nock'; + +export const mockResponse_accountsAPI_MultichainAccountBalances = ( + accountAddress: Hex, +) => ({ + count: 8, + balances: [ + { + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'MATIC', + name: 'MATIC', + type: 'native', + decimals: 18, + chainId: 137, + balance: '168.699548832017288710', + accountAddress: `eip155:137:${accountAddress}`, + }, + { + object: 'token', + address: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', + name: 'USD Coin (PoS)', + symbol: 'USDC', + decimals: 6, + balance: '8.174688', + chainId: 137, + accountAddress: `eip155:137:${accountAddress}`, + }, + { + object: 'token', + address: '0x53e0bca35ec356bd5dddfebbd1fc0fd03fabad39', + name: 'ChainLink Token', + symbol: 'LINK', + decimals: 18, + balance: '0.000734044925209136', + chainId: 137, + accountAddress: `eip155:137:${accountAddress}`, + }, + { + object: 'token', + address: '0x6d80113e533a2c0fe82eabd35f1875dcea89ea97', + name: 'Aave Polygon WMATIC', + symbol: 'aPolWMATIC', + decimals: 18, + balance: '1.001966754893761781', + chainId: 137, + accountAddress: `eip155:137:${accountAddress}`, + }, + ], + unprocessedNetworks: [], +}); + +export const mockAPI_accountsAPI_MultichainAccountBalances = ( + accountAddress: Hex, +) => + nock('https://accounts.api.cx.metamask.io/v4/multiaccount/balances') + .get('') + .query({ + accountAddresses: `eip155:137:${accountAddress}`, + }) + .reply( + 200, + mockResponse_accountsAPI_MultichainAccountBalances(accountAddress), + ); diff --git a/packages/assets-controllers/src/assetsUtil.test.ts b/packages/assets-controllers/src/assetsUtil.test.ts index e67bbb89280..c2c71646314 100644 --- a/packages/assets-controllers/src/assetsUtil.test.ts +++ b/packages/assets-controllers/src/assetsUtil.test.ts @@ -5,11 +5,13 @@ import { toHex, toChecksumHexAddress, } from '@metamask/controller-utils'; -import { add0x, type Hex } from '@metamask/utils'; +import { add0x } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import * as assetsUtil from './assetsUtil'; import { TOKEN_PRICES_BATCH_SIZE } from './assetsUtil'; import type { Nft, NftMetadata } from './NftController'; +import { getNativeTokenAddress } from './token-prices-service'; import type { AbstractTokenPricesService } from './token-prices-service'; const DEFAULT_IPFS_URL_FORMAT = 'ipfs://'; @@ -622,9 +624,10 @@ describe('assetsUtil', () => { const testChainId = '0x1'; const mockPriceService = createMockPriceService(); - jest.spyOn(mockPriceService, 'fetchTokenPrices').mockResolvedValue({ - [testTokenAddress]: { + jest.spyOn(mockPriceService, 'fetchTokenPrices').mockResolvedValue([ + { tokenAddress: testTokenAddress, + chainId: testChainId, currency: testNativeCurrency, allTimeHigh: 4000, allTimeLow: 900, @@ -645,7 +648,7 @@ describe('assetsUtil', () => { priceChange1d: 100, pricePercentChange1d: 100, }, - }); + ]); const result = await assetsUtil.fetchTokenContractExchangeRates({ tokenPricesService: mockPriceService, @@ -685,13 +688,21 @@ describe('assetsUtil', () => { ); expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(numBatches); + const tokenAddressesWithNativeToken = [ + getNativeTokenAddress(testChainId), + ...tokenAddresses, + ]; for (let i = 1; i <= numBatches; i++) { expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(i, { - chainId: testChainId, - tokenAddresses: tokenAddresses.slice( - (i - 1) * TOKEN_PRICES_BATCH_SIZE, - i * TOKEN_PRICES_BATCH_SIZE, - ), + assets: tokenAddressesWithNativeToken + .slice( + (i - 1) * TOKEN_PRICES_BATCH_SIZE, + i * TOKEN_PRICES_BATCH_SIZE, + ) + .map((tokenAddress) => ({ + chainId: testChainId, + tokenAddress, + })), currency: testNativeCurrency, }); } @@ -729,13 +740,21 @@ describe('assetsUtil', () => { ); expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(numBatches); + const tokenAddressesWithNativeToken = [ + getNativeTokenAddress(testChainId), + ...tokenAddresses, + ]; for (let i = 1; i <= numBatches; i++) { expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(i, { - chainId: testChainId, - tokenAddresses: tokenAddresses.slice( - (i - 1) * TOKEN_PRICES_BATCH_SIZE, - i * TOKEN_PRICES_BATCH_SIZE, - ), + assets: tokenAddressesWithNativeToken + .slice( + (i - 1) * TOKEN_PRICES_BATCH_SIZE, + i * TOKEN_PRICES_BATCH_SIZE, + ) + .map((tokenAddress) => ({ + chainId: testChainId, + tokenAddress, + })), currency: testNativeCurrency, }); } @@ -779,7 +798,7 @@ function createMockPriceService(): AbstractTokenPricesService { return true; }, async fetchTokenPrices() { - return {}; + return []; }, async fetchExchangeRates() { return {}; diff --git a/packages/assets-controllers/src/assetsUtil.ts b/packages/assets-controllers/src/assetsUtil.ts index 6827486f631..f48711894db 100644 --- a/packages/assets-controllers/src/assetsUtil.ts +++ b/packages/assets-controllers/src/assetsUtil.ts @@ -14,8 +14,10 @@ import BN from 'bn.js'; import { CID } from 'multiformats/cid'; import type { Nft, NftMetadata } from './NftController'; +import { getNativeTokenAddress } from './token-prices-service'; import type { AbstractTokenPricesService } from './token-prices-service'; -import { type ContractExchangeRates } from './TokenRatesController'; +import type { EvmAssetWithMarketData } from './token-prices-service/abstract-token-prices-service'; +import type { ContractExchangeRates } from './TokenRatesController'; /** * The maximum number of token addresses that should be sent to the Price API in @@ -370,17 +372,23 @@ export async function fetchTokenContractExchangeRates({ const tokenPricesByTokenAddress = await reduceInBatchesSerially< Hex, - Awaited> + Record >({ - values: [...tokenAddresses].sort(), + values: [...tokenAddresses, getNativeTokenAddress(chainId)].sort(), batchSize: TOKEN_PRICES_BATCH_SIZE, eachBatch: async (allTokenPricesByTokenAddress, batch) => { - const tokenPricesByTokenAddressForBatch = + const tokenPricesByTokenAddressForBatch = ( await tokenPricesService.fetchTokenPrices({ - tokenAddresses: batch, - chainId, + assets: batch.map((tokenAddress) => ({ + chainId, + tokenAddress, + })), currency: nativeCurrency, - }); + }) + ).reduce>((acc, tokenPrice) => { + acc[tokenPrice.tokenAddress] = tokenPrice; + return acc; + }, {}); return { ...allTokenPricesByTokenAddress, diff --git a/packages/assets-controllers/src/balances.ts b/packages/assets-controllers/src/balances.ts index a1dbade67ea..b261586b66e 100644 --- a/packages/assets-controllers/src/balances.ts +++ b/packages/assets-controllers/src/balances.ts @@ -1,7 +1,5 @@ -import { - parseAccountGroupId, - type AccountGroupId, -} from '@metamask/account-api'; +import { parseAccountGroupId } from '@metamask/account-api'; +import type { AccountGroupId } from '@metamask/account-api'; import type { AccountTreeControllerState } from '@metamask/account-tree-controller'; import type { AccountsControllerState } from '@metamask/accounts-controller'; import { isEvmAccountType } from '@metamask/keyring-api'; @@ -142,9 +140,7 @@ function getEvmTokenBalances( const { chainId, tokenAddress, balance } = tokenBalance; const stakingContractAddress = - STAKING_CONTRACT_ADDRESS_BY_CHAINID[ - chainId as keyof typeof STAKING_CONTRACT_ADDRESS_BY_CHAINID - ]; + STAKING_CONTRACT_ADDRESS_BY_CHAINID[chainId]; const isNative = tokenAddress === ZERO_ADDRESS; const isStakedNative = stakingContractAddress ? tokenAddress.toLowerCase() === stakingContractAddress.toLowerCase() @@ -163,8 +159,8 @@ function getEvmTokenBalances( // Get market data const marketDataAddress = isNative || isStakedNative - ? getNativeTokenAddress(chainId as Hex) - : (tokenAddress as Hex); + ? getNativeTokenAddress(chainId) + : tokenAddress; const tokenMarketData = tokenRatesState?.marketData?.[chainId]?.[marketDataAddress]; if (!tokenMarketData?.price) { @@ -185,9 +181,7 @@ function getEvmTokenBalances( const accountTokens = tokensState?.allTokens?.[chainId]?.[account.address]; const token = accountTokens?.find((t) => t.address === tokenAddress); - decimals = isNonNaNNumber(token?.decimals) - ? (token?.decimals as number) - : 18; + decimals = isNonNaNNumber(token?.decimals) ? token?.decimals : 18; } const decimalBalance = parseInt(balance, 16); if (!isNonNaNNumber(decimalBalance)) { diff --git a/packages/assets-controllers/src/crypto-compare-service/crypto-compare.ts b/packages/assets-controllers/src/crypto-compare-service/crypto-compare.ts index b204d1fd31b..5af6f3869d3 100644 --- a/packages/assets-controllers/src/crypto-compare-service/crypto-compare.ts +++ b/packages/assets-controllers/src/crypto-compare-service/crypto-compare.ts @@ -149,10 +149,9 @@ export async function fetchMultiExchangeRate( handleErrorResponse(response); const rates: Record> = {}; - for (const [cryptocurrency, values] of Object.entries(response) as [ - string, - Record, - ][]) { + for (const [cryptocurrency, values] of Object.entries>( + response, + )) { const key = getKeyByValue(nativeSymbolOverrides, cryptocurrency); rates[key?.toLowerCase() ?? cryptocurrency.toLowerCase()] = { [fiatCurrency.toLowerCase()]: values[fiatCurrency.toUpperCase()], diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts index 660b2a47af9..46ce0da79c1 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts @@ -1,11 +1,8 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import BN from 'bn.js'; -import { - AccountsApiBalanceFetcher, - type ChainIdHex, - type ChecksumAddress, -} from './api-balance-fetcher'; +import { AccountsApiBalanceFetcher } from './api-balance-fetcher'; +import type { ChainIdHex, ChecksumAddress } from './api-balance-fetcher'; import type { GetBalancesResponse } from './types'; import { SUPPORTED_NETWORKS_ACCOUNTS_API_V4 } from '../constants'; @@ -358,6 +355,7 @@ describe('AccountsApiBalanceFetcher', () => { ], }, 'extension', + undefined, ); expect(result.balances).toHaveLength(2); @@ -395,6 +393,7 @@ describe('AccountsApiBalanceFetcher', () => { ], }, 'extension', + undefined, ); expect(result.balances).toHaveLength(3); @@ -499,7 +498,7 @@ describe('AccountsApiBalanceFetcher', () => { expect(mockReduceInBatchesSerially).toHaveBeenCalledWith({ values: caipAddresses, - batchSize: 50, + batchSize: 20, eachBatch: expect.any(Function), initialResult: [], }); @@ -716,6 +715,7 @@ describe('AccountsApiBalanceFetcher', () => { ], }, 'extension', + undefined, ); }); @@ -737,6 +737,7 @@ describe('AccountsApiBalanceFetcher', () => { ], }, 'mobile', + undefined, ); }); }); @@ -2039,8 +2040,7 @@ describe('AccountsApiBalanceFetcher', () => { // Create 60 accounts to force batching (50 per batch) for (let i = 0; i < 60; i++) { - const address = - `0x${i.toString(16).padStart(40, '0')}` as ChecksumAddress; + const address = `0x${i.toString(16).padStart(40, '0')}` as const; largeAccountList.push({ id: i.toString(), address, @@ -2153,8 +2153,7 @@ describe('AccountsApiBalanceFetcher', () => { // Create 55 accounts to force batching for (let i = 0; i < 55; i++) { - const address = - `0x${i.toString(16).padStart(40, '0')}` as ChecksumAddress; + const address = `0x${i.toString(16).padStart(40, '0')}` as const; largeAccountList.push({ id: i.toString(), address, diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts index 80f5df7591d..8bd15a2a7cd 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts @@ -21,10 +21,10 @@ import { import { SUPPORTED_NETWORKS_ACCOUNTS_API_V4 } from '../constants'; // Maximum number of account addresses that can be sent to the accounts API in a single request -const ACCOUNTS_API_BATCH_SIZE = 50; +const ACCOUNTS_API_BATCH_SIZE = 20; -// Timeout for accounts API requests (30 seconds) -const ACCOUNTS_API_TIMEOUT_MS = 30_000; +// Timeout for accounts API requests (10 seconds) +const ACCOUNTS_API_TIMEOUT_MS = 10_000; export type ChainIdHex = Hex; export type ChecksumAddress = Hex; @@ -49,6 +49,7 @@ export type BalanceFetcher = { queryAllAccounts: boolean; selectedAccount: ChecksumAddress; allAccounts: InternalAccount[]; + jwtToken?: string; }): Promise; }; @@ -94,7 +95,7 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { for (const caipAddr of addrs) { const [, chainRef, address] = caipAddr.split(':'); - const chainId = toHex(parseInt(chainRef, 10)) as ChainIdHex; + const chainId = toHex(parseInt(chainRef, 10)); const checksumAddress = checksum(address); if (!addressesByChain[chainId]) { @@ -122,10 +123,7 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { continue; } - const contractAddress = - STAKING_CONTRACT_ADDRESS_BY_CHAINID[ - chainIdHex as keyof typeof STAKING_CONTRACT_ADDRESS_BY_CHAINID - ]; + const contractAddress = STAKING_CONTRACT_ADDRESS_BY_CHAINID[chainIdHex]; const provider = this.#getProvider(chainIdHex); const abi = [ @@ -172,7 +170,7 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { success: true, value: new BN((assets as BigNumber).toString()), account: address, - token: checksum(contractAddress) as ChecksumAddress, + token: checksum(contractAddress), chainId: chainIdHex, }); } @@ -182,7 +180,7 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { success: true, value: new BN('0'), account: address, - token: checksum(contractAddress) as ChecksumAddress, + token: checksum(contractAddress), chainId: chainIdHex, }); } @@ -195,7 +193,7 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { results.push({ success: false, account: address, - token: checksum(contractAddress) as ChecksumAddress, + token: checksum(contractAddress), chainId: chainIdHex, }); } @@ -211,12 +209,13 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { return results; } - async #fetchBalances(addrs: CaipAccountAddress[]) { + async #fetchBalances(addrs: CaipAccountAddress[], jwtToken?: string) { // If we have fewer than or equal to the batch size, make a single request if (addrs.length <= ACCOUNTS_API_BATCH_SIZE) { return await fetchMultiChainBalancesV4( { accountAddresses: addrs }, this.#platform, + jwtToken, ); } @@ -238,6 +237,7 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { const response = await fetchMultiChainBalancesV4( { accountAddresses: batch }, this.#platform, + jwtToken, ); // Collect unprocessed networks from each batch if (response.unprocessedNetworks) { @@ -261,6 +261,7 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { queryAllAccounts, selectedAccount, allAccounts, + jwtToken, }: Parameters[0]): Promise { const caipAddrs: CaipAccountAddress[] = []; @@ -281,7 +282,7 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { // Let errors propagate to TokenBalancesController for RPC fallback // Use timeout to prevent hanging API calls (30 seconds) const apiResponse = await safelyExecuteWithTimeout( - () => this.#fetchBalances(caipAddrs), + () => this.#fetchBalances(caipAddrs, jwtToken), false, // don't log error here, let it propagate ACCOUNTS_API_TIMEOUT_MS, ); @@ -294,9 +295,7 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { // Extract unprocessed networks and convert to hex chain IDs const unprocessedChainIds: ChainIdHex[] | undefined = apiResponse.unprocessedNetworks - ? apiResponse.unprocessedNetworks.map( - (chainId) => toHex(chainId) as ChainIdHex, - ) + ? apiResponse.unprocessedNetworks.map((chainId) => toHex(chainId)) : undefined; const stakedBalances = await this.#fetchStakedBalances(caipAddrs); @@ -307,7 +306,7 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { const addressChainMap = new Map>(); caipAddrs.forEach((caipAddr) => { const [, chainRef, address] = caipAddr.split(':'); - const chainId = toHex(parseInt(chainRef, 10)) as ChainIdHex; + const chainId = toHex(parseInt(chainRef, 10)); const checksumAddress = checksum(address); if (!addressChainMap.has(checksumAddress)) { @@ -335,7 +334,7 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { // by mgrating tokenBalancesController to checksum addresses const finalAccount: ChecksumAddress | string = token === ZERO_ADDRESS ? account : addressPart; - const chainId = toHex(b.chainId) as ChainIdHex; + const chainId = toHex(b.chainId); let value: BN | undefined; try { diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts index d6dd686ad03..184b06c6934 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts @@ -118,6 +118,54 @@ describe('fetchMultiChainBalancesV4()', () => { expect(mockAPI.isDone()).toBe(true); }); + it('should include JWT token in Authorization header when provided', async () => { + const mockJwtToken = 'test-jwt-token-v4-456'; + const mockAPI = createMockAPI() + .matchHeader('authorization', `Bearer ${mockJwtToken}`) + .reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalancesV4( + {}, + 'extension', + mockJwtToken, + ); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should work without JWT token when not provided', async () => { + const mockAPI = createMockAPI().reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalancesV4({}, 'extension'); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should include JWT token with account addresses and networks', async () => { + const mockJwtToken = 'test-jwt-token-v4-789'; + const mockAPI = createMockAPI() + .query({ + networks: '1,137', + accountAddresses: MOCK_CAIP_ADDRESSES.join(), + }) + .matchHeader('authorization', `Bearer ${mockJwtToken}`) + .reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalancesV4( + { + accountAddresses: MOCK_CAIP_ADDRESSES, + networks: [1, 137], + }, + 'extension', + mockJwtToken, + ); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + it('should successfully return balances response with account addresses', async () => { const mockAPI = createMockAPI() .query({ diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts index 067c6130190..14c52d44823 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts @@ -57,20 +57,29 @@ export async function fetchSupportedNetworks(): Promise { * @param options - params to pass down for a more refined search * @param options.networks - the networks (in decimal) that you want to filter by * @param platform - indicates whether the platform is extension or mobile + * @param jwtToken - JWT token for authentication * @returns a Balances Response */ export async function fetchMultiChainBalances( address: string, options: { networks?: number[] }, platform: 'extension' | 'mobile', + jwtToken?: string, ) { const url = getBalancesUrl(address, { networks: options?.networks?.join(), }); + + const headers: Record = { + 'x-metamask-clientproduct': `metamask-${platform}`, + }; + + if (jwtToken) { + headers.Authorization = `Bearer ${jwtToken}`; + } + const response: GetBalancesResponse = await handleFetch(url, { - headers: { - 'x-metamask-clientproduct': `metamask-${platform}`, - }, + headers, }); return response; } @@ -82,21 +91,29 @@ export async function fetchMultiChainBalances( * @param options.accountAddresses - the account addresses that you want to filter by * @param options.networks - the networks (in decimal) that you want to filter by * @param platform - indicates whether the platform is extension or mobile + * @param jwtToken - JWT token for authentication * @returns a Balances Response */ export async function fetchMultiChainBalancesV4( options: { accountAddresses?: CaipAccountAddress[]; networks?: number[] }, platform: 'extension' | 'mobile', + jwtToken?: string, ) { const url = getBalancesUrlV4({ accountAddresses: options?.accountAddresses?.join(), networks: options?.networks?.join(), }); + const headers: Record = { + 'x-metamask-clientproduct': `metamask-${platform}`, + }; + + if (jwtToken) { + headers.Authorization = `Bearer ${jwtToken}`; + } + const response: GetBalancesResponse = await handleFetch(url, { - headers: { - 'x-metamask-clientproduct': `metamask-${platform}`, - }, + headers, }); return response; } diff --git a/packages/assets-controllers/src/multicall.test.ts b/packages/assets-controllers/src/multicall.test.ts index 92f6199c9c9..d9795f8e591 100644 --- a/packages/assets-controllers/src/multicall.test.ts +++ b/packages/assets-controllers/src/multicall.test.ts @@ -10,8 +10,8 @@ import { aggregate3, getTokenBalancesForMultipleAddresses, getStakedBalancesForAddresses, - type Aggregate3Call, } from './multicall'; +import type { Aggregate3Call } from './multicall'; const provider = new Web3Provider(jest.fn()); diff --git a/packages/assets-controllers/src/multicall.ts b/packages/assets-controllers/src/multicall.ts index ca8b3e1296b..3b8a2f9568a 100644 --- a/packages/assets-controllers/src/multicall.ts +++ b/packages/assets-controllers/src/multicall.ts @@ -297,6 +297,10 @@ const MULTICALL_CONTRACT_BY_CHAINID = { '0x8f': '0xcA11bde05977b3631167028862bE2a173976CA11', // XDC, contract found but not in multicall3 repo '0x32': '0x0B1795ccA8E4eC4df02346a082df54D437F8D9aF', + // MegaETH TESTNET v2 (timothy chain ID 6343) + '0x18c7': '0xcA11bde05977b3631167028862bE2a173976CA11', + // MegaETH mainnet, contract found matching multicall3 bytecode + '0x10e6': '0xcA11bde05977b3631167028862bE2a173976CA11', } as Record; const multicallAbi = [ @@ -797,10 +801,7 @@ const getStakedBalancesFallback = async ( ): Promise> => { const stakedBalanceMap: Record = {}; - const stakingContractAddress = - STAKING_CONTRACT_ADDRESS_BY_CHAINID[ - chainId as keyof typeof STAKING_CONTRACT_ADDRESS_BY_CHAINID - ]; + const stakingContractAddress = STAKING_CONTRACT_ADDRESS_BY_CHAINID[chainId]; if (!stakingContractAddress) { // No staking support for this chain @@ -850,10 +851,7 @@ export const getStakedBalancesForAddresses = async ( chainId: Hex, provider: Web3Provider, ): Promise> => { - const stakingContractAddress = - STAKING_CONTRACT_ADDRESS_BY_CHAINID[ - chainId as keyof typeof STAKING_CONTRACT_ADDRESS_BY_CHAINID - ]; + const stakingContractAddress = STAKING_CONTRACT_ADDRESS_BY_CHAINID[chainId]; if (!stakingContractAddress) { return {}; @@ -974,11 +972,7 @@ export const getTokenBalancesForMultipleAddresses = async ( ); // Check if Multicall3 is supported on this chain - if ( - !MULTICALL_CONTRACT_BY_CHAINID[ - chainId as keyof typeof MULTICALL_CONTRACT_BY_CHAINID - ] - ) { + if (!MULTICALL_CONTRACT_BY_CHAINID[chainId]) { // Fallback to individual balance calls when Multicall3 is not supported const tokenBalances = await getTokenBalancesFallback( uniqueTokenAddresses, diff --git a/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.test.ts b/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.test.ts index 717541724c9..db0f93f3e9c 100644 --- a/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.test.ts +++ b/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.test.ts @@ -3,11 +3,8 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { NetworkClient } from '@metamask/network-controller'; import BN from 'bn.js'; -import { - RpcBalanceFetcher, - type ChainIdHex, - type ChecksumAddress, -} from './rpc-balance-fetcher'; +import { RpcBalanceFetcher } from './rpc-balance-fetcher'; +import type { ChainIdHex, ChecksumAddress } from './rpc-balance-fetcher'; import type { TokensControllerState } from '../TokensController'; const MOCK_ADDRESS_1 = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; diff --git a/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.ts b/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.ts index 71990f78aff..6dba8cf39dd 100644 --- a/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.ts +++ b/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.ts @@ -74,9 +74,7 @@ export class RpcBalanceFetcher implements BalanceFetcher { } #getStakingContractAddress(chainId: ChainIdHex): string | undefined { - return STAKING_CONTRACT_ADDRESS_BY_CHAINID[ - chainId as keyof typeof STAKING_CONTRACT_ADDRESS_BY_CHAINID - ]; + return STAKING_CONTRACT_ADDRESS_BY_CHAINID[chainId]; } async fetch({ @@ -136,7 +134,7 @@ export class RpcBalanceFetcher implements BalanceFetcher { const nativeBalance = tokenBalances[ZERO_ADDRESS]?.[address] || null; chainResults.push({ success: true, - value: nativeBalance ? (nativeBalance as BN) : new BN('0'), + value: nativeBalance || new BN('0'), account: address as ChecksumAddress, token: ZERO_ADDRESS, chainId, @@ -152,7 +150,7 @@ export class RpcBalanceFetcher implements BalanceFetcher { Object.entries(balances).forEach(([acct, bn]) => { chainResults.push({ success: bn !== null, - value: bn as BN, + value: bn, account: acct as ChecksumAddress, token: checksum(tokenAddr), chainId, @@ -175,7 +173,7 @@ export class RpcBalanceFetcher implements BalanceFetcher { const stakedBalance = stakedBalances?.[address] || null; chainResults.push({ success: true, - value: stakedBalance ? (stakedBalance as BN) : new BN('0'), + value: stakedBalance || new BN('0'), account: address as ChecksumAddress, token: checksummedStakingAddress, chainId, @@ -248,7 +246,7 @@ function buildAccountTokenGroupsStatic( if (!shouldInclude) { return; } - (tokens as unknown[]).forEach((t: unknown) => + tokens.forEach((t: unknown) => pairs.push({ accountAddress: account as ChecksumAddress, tokenAddress: checksum((t as { address: string }).address), diff --git a/packages/assets-controllers/src/selectors/stringify-balance.ts b/packages/assets-controllers/src/selectors/stringify-balance.ts index fe743529724..acfcce77490 100644 --- a/packages/assets-controllers/src/selectors/stringify-balance.ts +++ b/packages/assets-controllers/src/selectors/stringify-balance.ts @@ -1,7 +1,8 @@ // From https://github.com/MetaMask/eth-token-tracker/blob/main/lib/util.js // Ensures backwards compatibility with display formatting. -import { bigIntToHex, type Hex } from '@metamask/utils'; +import { bigIntToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; /** * @param balance - The balance to stringify as a decimal string diff --git a/packages/assets-controllers/src/selectors/token-selectors.test.ts b/packages/assets-controllers/src/selectors/token-selectors.test.ts index a912b14da1e..0bd2892f3bb 100644 --- a/packages/assets-controllers/src/selectors/token-selectors.test.ts +++ b/packages/assets-controllers/src/selectors/token-selectors.test.ts @@ -992,7 +992,7 @@ describe('token-selectors', () => { Object.values(MOCK_TRON_TOKENS) .flat() .forEach((token) => { - if (token.fiat && token.fiat.conversionRate) { + if (token.fiat?.conversionRate) { state.conversionRates[token.assetId] = { rate: token.fiat.conversionRate.toString(), conversionTime: Date.now(), diff --git a/packages/assets-controllers/src/selectors/token-selectors.ts b/packages/assets-controllers/src/selectors/token-selectors.ts index 741d1174e3f..d807603d004 100644 --- a/packages/assets-controllers/src/selectors/token-selectors.ts +++ b/packages/assets-controllers/src/selectors/token-selectors.ts @@ -5,7 +5,8 @@ import { convertHexToDecimal } from '@metamask/controller-utils'; import { TrxScope } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { NetworkState } from '@metamask/network-controller'; -import { hexToBigInt, parseCaipAssetType, type Hex } from '@metamask/utils'; +import { hexToBigInt, parseCaipAssetType } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { createSelector, weakMapMemoize } from 'reselect'; import { diff --git a/packages/assets-controllers/src/token-prices-service/abstract-token-prices-service.ts b/packages/assets-controllers/src/token-prices-service/abstract-token-prices-service.ts index ddc7a3e159b..22577107fa3 100644 --- a/packages/assets-controllers/src/token-prices-service/abstract-token-prices-service.ts +++ b/packages/assets-controllers/src/token-prices-service/abstract-token-prices-service.ts @@ -1,31 +1,7 @@ import type { ServicePolicy } from '@metamask/controller-utils'; -import type { Hex } from '@metamask/utils'; +import type { CaipAssetType, Hex } from '@metamask/utils'; -/** - * Represents the price of a token in a currency. - */ -export type TokenPrice = { - tokenAddress: TokenAddress; - currency: Currency; - allTimeHigh: number; - allTimeLow: number; - circulatingSupply: number; - dilutedMarketCap: number; - high1d: number; - low1d: number; - marketCap: number; - marketCapPercentChange1d: number; - price: number; - priceChange1d: number; - pricePercentChange1d: number; - pricePercentChange1h: number; - pricePercentChange1y: number; - pricePercentChange7d: number; - pricePercentChange14d: number; - pricePercentChange30d: number; - pricePercentChange200d: number; - totalVolume: number; -}; +import type { MarketDataDetails } from '../TokenRatesController'; /** * Represents an exchange rate. @@ -38,16 +14,6 @@ export type ExchangeRate = { usd?: number; }; -/** - * A map of token address to its price. - */ -export type TokenPricesByTokenAddress< - TokenAddress extends Hex, - Currency extends string, -> = { - [A in TokenAddress]: TokenPrice; -}; - /** * A map of currency to its exchange rate. */ @@ -55,22 +21,33 @@ export type ExchangeRatesByCurrency = { [C in Currency]: ExchangeRate; }; +export type EvmAssetAddressWithChain = { + tokenAddress: Hex; + chainId: ChainId; +}; + +export type EvmAssetWithId = + EvmAssetAddressWithChain & { + assetId: CaipAssetType; + }; + +export type EvmAssetWithMarketData< + ChainId extends Hex = Hex, + Currency extends string = string, +> = EvmAssetAddressWithChain & + MarketDataDetails & { currency: Currency }; + /** * An ideal token prices service. All implementations must confirm to this * interface. * * @template ChainId - A type union of valid arguments for the `chainId` * argument to `fetchTokenPrices`. - * @template TokenAddress - A type union of all token addresses. The reason this - * type parameter exists is so that we can guarantee that same addresses that - * `fetchTokenPrices` receives are the same addresses that shown up in the - * return value. * @template Currency - A type union of valid arguments for the `currency` * argument to `fetchTokenPrices`. */ export type AbstractTokenPricesService< ChainId extends Hex = Hex, - TokenAddress extends Hex = Hex, Currency extends string = string, > = Partial> & { /** @@ -78,20 +55,17 @@ export type AbstractTokenPricesService< * given addresses which are expected to live on the given chain. * * @param args - The arguments to this function. - * @param args.chainId - An EIP-155 chain ID. - * @param args.tokenAddresses - Addresses for tokens that live on the chain. + * @param args.assets - The assets to get prices for. * @param args.currency - The desired currency of the token prices. * @returns The prices for the requested tokens. */ fetchTokenPrices({ - chainId, - tokenAddresses, + assets, currency, }: { - chainId: ChainId; - tokenAddresses: TokenAddress[]; + assets: EvmAssetAddressWithChain[]; currency: Currency; - }): Promise>>; + }): Promise[]>; /** * Retrieves exchange rates in the given currency. diff --git a/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts b/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts index 964dbe669bc..dc8c867d7c0 100644 --- a/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts @@ -1,3 +1,5 @@ +import { KnownCaipNamespace } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import nock from 'nock'; import { useFakeTimers } from 'sinon'; @@ -31,10 +33,9 @@ describe('CodefiTokenPricesServiceV2', () => { const maximumConsecutiveFailures = (1 + retries) * 3; // Initial interceptor for failing requests nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) @@ -42,36 +43,14 @@ describe('CodefiTokenPricesServiceV2', () => { .replyWithError('Failed to fetch'); // This interceptor should not be used nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - marketCap: 117219.99428314982, - allTimeHigh: 0.00060467892389492, - allTimeLow: 0.00002303954000865728, - totalVolume: 5155.094053542448, - high1d: 0.00008020715848194385, - low1d: 0.00007792083564549064, - circulatingSupply: 1494269733.9526057, - dilutedMarketCap: 117669.5125951733, - marketCapPercentChange1d: 0.76671, - pricePercentChange1h: -1.0736342953259423, - pricePercentChange7d: -7.351582573655089, - pricePercentChange14d: -1.0799098946709822, - pricePercentChange30d: -25.776321124365992, - pricePercentChange200d: 46.091571238599165, - pricePercentChange1y: -2.2992517267242754, - }, - '0xaaa': { + [buildTokenAssetId('0xAAA')]: { price: 148.17205755299946, currency: 'ETH', pricePercentChange1d: 1, @@ -92,7 +71,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xbbb': { + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, currency: 'ETH', pricePercentChange1d: 1, @@ -113,7 +92,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, currency: 'ETH', pricePercentChange1d: 1, @@ -146,8 +125,20 @@ describe('CodefiTokenPricesServiceV2', () => { service.onBreak(onBreakHandler); const fetchTokenPrices = () => service.fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }); expect(onBreakHandler).not.toHaveBeenCalled(); @@ -184,34 +175,27 @@ describe('CodefiTokenPricesServiceV2', () => { const degradedThreshold = 1000; const retries = 0; nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) .delay(degradedThreshold * 2) .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - }, - '0xaaa': { + [buildTokenAssetId('0xAAA')]: { price: 148.17205755299946, currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, }, - '0xbbb': { + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, currency: 'ETH', pricePercentChange1d: 1, @@ -229,8 +213,20 @@ describe('CodefiTokenPricesServiceV2', () => { clock, fetchTokenPrices: () => service.fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }), retries, @@ -241,18 +237,17 @@ describe('CodefiTokenPricesServiceV2', () => { }); describe('fetchTokenPrices', () => { - it('uses the /spot-prices endpoint of the Codefi Price API to gather prices for the given tokens', async () => { + it('uses the /v2/chains/{chainId}/spot-prices endpoint to gather prices forn chains not supported by v3', async () => { nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v2/chains/0x52/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + tokenAddresses: ['0xAAA', '0xBBB'].join(','), vsCurrency: 'ETH', includeMarketData: 'true', }) .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, + '0xaaa': { + price: 148.17205755299946, currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -272,7 +267,104 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xaaa': { + '0xbbb': { + price: 33689.98134554716, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + }); + + const marketDataTokensByAddress = + await new CodefiTokenPricesServiceV2().fetchTokenPrices({ + assets: [ + { + chainId: '0x52', + tokenAddress: '0xAAA', + }, + { + chainId: '0x52', + tokenAddress: '0xBBB', + }, + ], + currency: 'ETH', + }); + + expect(marketDataTokensByAddress).toStrictEqual([ + { + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + chainId: '0x52', + circulatingSupply: 1494269733.9526057, + currency: 'ETH', + dilutedMarketCap: 117669.5125951733, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + marketCap: 117219.99428314982, + marketCapPercentChange1d: 0.76671, + price: 148.17205755299946, + priceChange1d: 1, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange1d: 1, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange1y: -2.2992517267242754, + pricePercentChange200d: 46.091571238599165, + pricePercentChange30d: -25.776321124365992, + pricePercentChange7d: -7.351582573655089, + tokenAddress: '0xAAA', + totalVolume: 5155.094053542448, + }, + { + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + chainId: '0x52', + circulatingSupply: 1494269733.9526057, + currency: 'ETH', + dilutedMarketCap: 117669.5125951733, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + marketCap: 117219.99428314982, + marketCapPercentChange1d: 0.76671, + price: 33689.98134554716, + priceChange1d: 1, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange1d: 1, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange1y: -2.2992517267242754, + pricePercentChange200d: 46.091571238599165, + pricePercentChange30d: -25.776321124365992, + pricePercentChange7d: -7.351582573655089, + tokenAddress: '0xBBB', + totalVolume: 5155.094053542448, + }, + ]); + }); + + it('uses the /spot-prices endpoint of the Codefi Price API to gather prices for the given tokens', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .reply(200, { + [buildTokenAssetId('0xAAA')]: { price: 148.17205755299946, currency: 'ETH', pricePercentChange1d: 1, @@ -293,7 +385,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xbbb': { + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, currency: 'ETH', pricePercentChange1d: 1, @@ -314,7 +406,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, currency: 'ETH', pricePercentChange1d: 1, @@ -339,36 +431,28 @@ describe('CodefiTokenPricesServiceV2', () => { const marketDataTokensByAddress = await new CodefiTokenPricesServiceV2().fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }); - expect(marketDataTokensByAddress).toStrictEqual({ - '0x0000000000000000000000000000000000000000': { - tokenAddress: '0x0000000000000000000000000000000000000000', - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - marketCap: 117219.99428314982, - allTimeHigh: 0.00060467892389492, - allTimeLow: 0.00002303954000865728, - totalVolume: 5155.094053542448, - high1d: 0.00008020715848194385, - low1d: 0.00007792083564549064, - price: 14, - circulatingSupply: 1494269733.9526057, - dilutedMarketCap: 117669.5125951733, - marketCapPercentChange1d: 0.76671, - pricePercentChange1h: -1.0736342953259423, - pricePercentChange7d: -7.351582573655089, - pricePercentChange14d: -1.0799098946709822, - pricePercentChange30d: -25.776321124365992, - pricePercentChange200d: 46.091571238599165, - pricePercentChange1y: -2.2992517267242754, - }, - '0xAAA': { + expect(marketDataTokensByAddress).toStrictEqual([ + { tokenAddress: '0xAAA', + assetId: buildTokenAssetId('0xAAA'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -389,8 +473,10 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xBBB': { + { tokenAddress: '0xBBB', + assetId: buildTokenAssetId('0xBBB'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -411,8 +497,10 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xCCC': { + { tokenAddress: '0xCCC', + assetId: buildTokenAssetId('0xCCC'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -433,86 +521,55 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - }); + ]); }); - it('calls the /spot-prices endpoint using the correct native token address', async () => { - const mockPriceAPI = nock('https://price.api.cx.metamask.io') - .get('/v2/chains/137/spot-prices') + it('handles native token addresses', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') .query({ - tokenAddresses: '0x0000000000000000000000000000000000001010', + assetIds: buildMultipleAssetIds([ZERO_ADDRESS]), vsCurrency: 'ETH', includeMarketData: 'true', }) .reply(200, { - '0x0000000000000000000000000000000000001010': { - price: 14, + [buildTokenAssetId(ZERO_ADDRESS)]: { + price: 33689.98134554716, currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - marketCap: 117219.99428314982, - allTimeHigh: 0.00060467892389492, - allTimeLow: 0.00002303954000865728, - totalVolume: 5155.094053542448, - high1d: 0.00008020715848194385, - low1d: 0.00007792083564549064, - circulatingSupply: 1494269733.9526057, - dilutedMarketCap: 117669.5125951733, - marketCapPercentChange1d: 0.76671, - pricePercentChange1h: -1.0736342953259423, - pricePercentChange7d: -7.351582573655089, - pricePercentChange14d: -1.0799098946709822, - pricePercentChange30d: -25.776321124365992, - pricePercentChange200d: 46.091571238599165, - pricePercentChange1y: -2.2992517267242754, }, }); - const marketData = - await new CodefiTokenPricesServiceV2().fetchTokenPrices({ - chainId: '0x89', - tokenAddresses: [], - currency: 'ETH', - }); + const result = await new CodefiTokenPricesServiceV2().fetchTokenPrices({ + assets: [ + { + chainId: '0x1', + tokenAddress: ZERO_ADDRESS, + }, + ], + currency: 'ETH', + }); - expect(mockPriceAPI.isDone()).toBe(true); - expect( - marketData['0x0000000000000000000000000000000000001010'], - ).toBeDefined(); + expect(result).toStrictEqual([ + { + tokenAddress: ZERO_ADDRESS, + assetId: buildTokenAssetId(ZERO_ADDRESS), + chainId: '0x1', + currency: 'ETH', + price: 33689.98134554716, + }, + ]); }); it('should not include token price object for token address when token price in not included the response data', async () => { nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - marketCap: 117219.99428314982, - allTimeHigh: 0.00060467892389492, - allTimeLow: 0.00002303954000865728, - totalVolume: 5155.094053542448, - high1d: 0.00008020715848194385, - low1d: 0.00007792083564549064, - circulatingSupply: 1494269733.9526057, - dilutedMarketCap: 117669.5125951733, - marketCapPercentChange1d: 0.76671, - pricePercentChange1h: -1.0736342953259423, - pricePercentChange7d: -7.351582573655089, - pricePercentChange14d: -1.0799098946709822, - pricePercentChange30d: -25.776321124365992, - pricePercentChange200d: 46.091571238599165, - pricePercentChange1y: -2.2992517267242754, - }, - '0xbbb': { + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, currency: 'ETH', pricePercentChange1d: 1, @@ -533,7 +590,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, currency: 'ETH', pricePercentChange1d: 1, @@ -557,35 +614,27 @@ describe('CodefiTokenPricesServiceV2', () => { }); const result = await new CodefiTokenPricesServiceV2().fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }); - expect(result).toStrictEqual({ - '0x0000000000000000000000000000000000000000': { - tokenAddress: '0x0000000000000000000000000000000000000000', - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - marketCap: 117219.99428314982, - allTimeHigh: 0.00060467892389492, - allTimeLow: 0.00002303954000865728, - totalVolume: 5155.094053542448, - high1d: 0.00008020715848194385, - low1d: 0.00007792083564549064, - price: 14, - circulatingSupply: 1494269733.9526057, - dilutedMarketCap: 117669.5125951733, - marketCapPercentChange1d: 0.76671, - pricePercentChange1h: -1.0736342953259423, - pricePercentChange7d: -7.351582573655089, - pricePercentChange14d: -1.0799098946709822, - pricePercentChange30d: -25.776321124365992, - pricePercentChange200d: 46.091571238599165, - pricePercentChange1y: -2.2992517267242754, - }, - '0xBBB': { + expect(result).toStrictEqual([ + { tokenAddress: '0xBBB', + assetId: buildTokenAssetId('0xBBB'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -606,8 +655,10 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xCCC': { + { tokenAddress: '0xCCC', + assetId: buildTokenAssetId('0xCCC'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -628,21 +679,20 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - }); + ]); }); it('should not include token price object for token address when price is undefined for token response data', async () => { nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) .reply(200, { - '0xaaa': {}, - '0xbbb': { + [buildTokenAssetId('0xAAA')]: {}, + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, pricePercentChange1d: 1, priceChange1d: 1, @@ -662,7 +712,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, pricePercentChange1d: 1, priceChange1d: 1, @@ -685,18 +735,34 @@ describe('CodefiTokenPricesServiceV2', () => { }); const result = await new CodefiTokenPricesServiceV2().fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }); - expect(result).toStrictEqual({ - '0xAAA': { - currency: 'ETH', + expect(result).toStrictEqual([ + { tokenAddress: '0xAAA', + assetId: buildTokenAssetId('0xAAA'), + chainId: '0x1', + currency: 'ETH', }, - '0xBBB': { + { tokenAddress: '0xBBB', + assetId: buildTokenAssetId('0xBBB'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -717,8 +783,10 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xCCC': { + { tokenAddress: '0xCCC', + assetId: buildTokenAssetId('0xCCC'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -739,65 +807,70 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - }); + ]); }); it('should correctly handle null market data for a token address', async () => { nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - }, - '0xaaa': null, // Simulating API returning null for market data - '0xbbb': { + [buildTokenAssetId('0xAAA')]: null, // Simulating API returning null for market data + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, currency: 'ETH', }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, currency: 'ETH', }, }); const result = await new CodefiTokenPricesServiceV2().fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }); - expect(result).toStrictEqual({ - '0x0000000000000000000000000000000000000000': { - tokenAddress: '0x0000000000000000000000000000000000000000', - currency: 'ETH', - price: 14, - }, - '0xBBB': { + expect(result).toStrictEqual([ + { tokenAddress: '0xBBB', + assetId: buildTokenAssetId('0xBBB'), + chainId: '0x1', currency: 'ETH', price: 33689.98134554716, }, - '0xCCC': { + { tokenAddress: '0xCCC', + assetId: buildTokenAssetId('0xCCC'), + chainId: '0x1', currency: 'ETH', price: 148.1344197578456, }, - }); + ]); }); it('throws if the request fails consistently', async () => { nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) @@ -806,8 +879,20 @@ describe('CodefiTokenPricesServiceV2', () => { await expect( new CodefiTokenPricesServiceV2().fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }), ).rejects.toThrow('Failed to fetch'); @@ -816,10 +901,9 @@ describe('CodefiTokenPricesServiceV2', () => { it('throws if the initial request and all retries fail', async () => { const retries = 3; nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) @@ -828,8 +912,20 @@ describe('CodefiTokenPricesServiceV2', () => { await expect( new CodefiTokenPricesServiceV2({ retries }).fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }), ).rejects.toThrow('Failed to fetch'); @@ -839,10 +935,9 @@ describe('CodefiTokenPricesServiceV2', () => { const retries = 3; // Initial interceptor for failing requests nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) @@ -850,36 +945,14 @@ describe('CodefiTokenPricesServiceV2', () => { .replyWithError('Failed to fetch'); // Interceptor for successful request nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - marketCap: 117219.99428314982, - allTimeHigh: 0.00060467892389492, - allTimeLow: 0.00002303954000865728, - totalVolume: 5155.094053542448, - high1d: 0.00008020715848194385, - low1d: 0.00007792083564549064, - circulatingSupply: 1494269733.9526057, - dilutedMarketCap: 117669.5125951733, - marketCapPercentChange1d: 0.76671, - pricePercentChange1h: -1.0736342953259423, - pricePercentChange7d: -7.351582573655089, - pricePercentChange14d: -1.0799098946709822, - pricePercentChange30d: -25.776321124365992, - pricePercentChange200d: 46.091571238599165, - pricePercentChange1y: -2.2992517267242754, - }, - '0xaaa': { + [buildTokenAssetId('0xAAA')]: { price: 148.17205755299946, currency: 'ETH', pricePercentChange1d: 1, @@ -900,7 +973,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xbbb': { + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, currency: 'ETH', pricePercentChange1d: 1, @@ -921,7 +994,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, currency: 'ETH', pricePercentChange1d: 1, @@ -947,36 +1020,28 @@ describe('CodefiTokenPricesServiceV2', () => { const marketDataTokensByAddress = await new CodefiTokenPricesServiceV2({ retries, }).fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }); - expect(marketDataTokensByAddress).toStrictEqual({ - '0x0000000000000000000000000000000000000000': { - tokenAddress: '0x0000000000000000000000000000000000000000', - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - marketCap: 117219.99428314982, - allTimeHigh: 0.00060467892389492, - allTimeLow: 0.00002303954000865728, - totalVolume: 5155.094053542448, - high1d: 0.00008020715848194385, - low1d: 0.00007792083564549064, - price: 14, - circulatingSupply: 1494269733.9526057, - dilutedMarketCap: 117669.5125951733, - marketCapPercentChange1d: 0.76671, - pricePercentChange1h: -1.0736342953259423, - pricePercentChange7d: -7.351582573655089, - pricePercentChange14d: -1.0799098946709822, - pricePercentChange30d: -25.776321124365992, - pricePercentChange200d: 46.091571238599165, - pricePercentChange1y: -2.2992517267242754, - }, - '0xAAA': { + expect(marketDataTokensByAddress).toStrictEqual([ + { tokenAddress: '0xAAA', + assetId: buildTokenAssetId('0xAAA'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -997,8 +1062,10 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xBBB': { + { tokenAddress: '0xBBB', + assetId: buildTokenAssetId('0xBBB'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -1019,8 +1086,10 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xCCC': { + { tokenAddress: '0xCCC', + assetId: buildTokenAssetId('0xCCC'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -1041,7 +1110,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - }); + ]); }); describe('before circuit break', () => { @@ -1059,34 +1128,27 @@ describe('CodefiTokenPricesServiceV2', () => { const degradedThreshold = 1000; const retries = 0; nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) .delay(degradedThreshold * 2) .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - }, - '0xaaa': { + [buildTokenAssetId('0xAAA')]: { price: 148.17205755299946, currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, }, - '0xbbb': { + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, currency: 'ETH', pricePercentChange1d: 1, @@ -1104,8 +1166,20 @@ describe('CodefiTokenPricesServiceV2', () => { clock, fetchTokenPrices: () => service.fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }), retries, @@ -1132,10 +1206,9 @@ describe('CodefiTokenPricesServiceV2', () => { const maximumConsecutiveFailures = (1 + retries) * 3; // Initial interceptor for failing requests nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) @@ -1143,36 +1216,14 @@ describe('CodefiTokenPricesServiceV2', () => { .replyWithError('Failed to fetch'); // This interceptor should not be used nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - marketCap: 117219.99428314982, - allTimeHigh: 0.00060467892389492, - allTimeLow: 0.00002303954000865728, - totalVolume: 5155.094053542448, - high1d: 0.00008020715848194385, - low1d: 0.00007792083564549064, - circulatingSupply: 1494269733.9526057, - dilutedMarketCap: 117669.5125951733, - marketCapPercentChange1d: 0.76671, - pricePercentChange1h: -1.0736342953259423, - pricePercentChange7d: -7.351582573655089, - pricePercentChange14d: -1.0799098946709822, - pricePercentChange30d: -25.776321124365992, - pricePercentChange200d: 46.091571238599165, - pricePercentChange1y: -2.2992517267242754, - }, - '0xaaa': { + [buildTokenAssetId('0xAAA')]: { price: 148.17205755299946, currency: 'ETH', pricePercentChange1d: 1, @@ -1193,7 +1244,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xbbb': { + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, currency: 'ETH', pricePercentChange1d: 1, @@ -1214,7 +1265,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, currency: 'ETH', pricePercentChange1d: 1, @@ -1247,8 +1298,20 @@ describe('CodefiTokenPricesServiceV2', () => { }); const fetchTokenPrices = () => service.fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }); expect(onBreakHandler).not.toHaveBeenCalled(); @@ -1892,3 +1955,23 @@ async function fetchExchangeRatesWithFakeTimers({ return await pendingUpdate; } + +/** + * + * @param tokenAddress - The token address. + * @returns The token asset id. + */ +function buildTokenAssetId(tokenAddress: Hex): string { + return tokenAddress === ZERO_ADDRESS + ? `${KnownCaipNamespace.Eip155}:1/slip44:60` + : `${KnownCaipNamespace.Eip155}:1/erc20:${tokenAddress.toLowerCase()}`; +} + +/** + * + * @param tokenAddresses - The token addresses. + * @returns The token asset ids. + */ +function buildMultipleAssetIds(tokenAddresses: Hex[]): string { + return tokenAddresses.map(buildTokenAssetId).join(','); +} diff --git a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts index 5ec2ddcee3c..d99aed5eba2 100644 --- a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts @@ -7,15 +7,21 @@ import { handleFetch, } from '@metamask/controller-utils'; import type { ServicePolicy } from '@metamask/controller-utils'; -import type { Hex } from '@metamask/utils'; -import { hexToNumber } from '@metamask/utils'; +import type { CaipAssetType, Hex } from '@metamask/utils'; +import { + hexToNumber, + KnownCaipNamespace, + toCaipChainId, +} from '@metamask/utils'; import type { AbstractTokenPricesService, + EvmAssetAddressWithChain, + EvmAssetWithId, + EvmAssetWithMarketData, ExchangeRatesByCurrency, - TokenPrice, - TokenPricesByTokenAddress, } from './abstract-token-prices-service'; +import type { MarketDataDetails } from '../TokenRatesController'; /** * The list of currencies that can be supplied as the `vsCurrency` parameter to @@ -76,6 +82,8 @@ export const SUPPORTED_CURRENCIES = [ 'eur', // British Pound Sterling 'gbp', + // Georgian Lari + 'gel', // Hong Kong Dollar 'hkd', // Hungarian Forint @@ -100,6 +108,8 @@ export const SUPPORTED_CURRENCIES = [ 'mxn', // Malaysian Ringgit 'myr', + // Monad + 'mon', // Nigerian Naira 'ngn', // Norwegian Krone @@ -144,6 +154,52 @@ export const SUPPORTED_CURRENCIES = [ 'bits', // Satoshi 'sats', + // Colombian Peso + 'cop', + // Kenyan Shilling + 'kes', + // Romanian Leu + 'ron', + // Dominican Peso + 'dop', + // Costa Rican Colón + 'crc', + // Honduran Lempira + 'hnl', + // Zambian Kwacha + 'zmw', + // Salvadoran Colón + 'svc', + // Bosnia-Herzegovina Convertible Mark + 'bam', + // Peruvian Sol + 'pen', + // Guatemalan Quetzal + 'gtq', + // Lebanese Pound + 'lbp', + // Armenian Dram + 'amd', + // Solana + 'sol', + // Sei + 'sei', + // Sonic + 'sonic', + // Tron + 'trx', + // Taiko + 'taiko', + // Pepu + 'pepu', + // Polygon + 'pol', + // Mantle + 'mnt', + // Onomy + 'nom', + // Avalanche + 'avax', ] as const; /** @@ -160,7 +216,9 @@ export const ZERO_ADDRESS: Hex = * Only for chains whose native tokens have a specific address. */ const chainIdToNativeTokenAddress: Record = { - '0x89': '0x0000000000000000000000000000000000001010', + '0x89': '0x0000000000000000000000000000000000001010', // Polygon + '0x440': '0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // Metis Andromeda + '0x1388': '0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // Mantle }; /** @@ -174,6 +232,56 @@ const chainIdToNativeTokenAddress: Record = { export const getNativeTokenAddress = (chainId: Hex): Hex => chainIdToNativeTokenAddress[chainId] ?? ZERO_ADDRESS; +// Source: https://github.com/consensys-vertical-apps/va-mmcx-price-api/blob/main/src/constants/slip44.ts +// We can only support PricesAPI V3 for EVM chains that have a CAIP-19 native asset mapping. +export const SPOT_PRICES_SUPPORT_INFO = { + '0x1': 'eip155:1/slip44:60', // Ethereum Mainnet - Native symbol: ETH + '0xa': 'eip155:10/slip44:60', // OP Mainnet - Native symbol: ETH + '0x19': 'eip155:25/slip44:394', // Cronos Mainnet - Native symbol: CRO + '0x38': 'eip155:56/slip44:714', // BNB Smart Chain Mainnet - Native symbol: BNB + '0x39': 'eip155:57/erc20:0x0000000000000000000000000000000000000000', // 'eip155:57/slip44:57', // Syscoin Mainnet - Native symbol: SYS + '0x52': null, // 'eip155:82/slip44:18000', // Meter Mainnet - Native symbol: MTR + '0x58': 'eip155:88/erc20:0x0000000000000000000000000000000000000000', // 'eip155:88/slip44:889', // TomoChain - Native symbol: TOMO + '0x64': 'eip155:100/slip44:700', // Gnosis (formerly xDAI Chain) - Native symbol: xDAI + '0x6a': 'eip155:106/erc20:0x0000000000000000000000000000000000000000', // 'eip155:106/slip44:5655640', // Velas EVM Mainnet - Native symbol: VLX + '0x80': 'eip155:128/erc20:0x0000000000000000000000000000000000000000', // 'eip155:128/slip44:1010', // Huobi ECO Chain Mainnet - Native symbol: HT + '0x89': 'eip155:137/slip44:966', // Polygon Mainnet - Native symbol: POL + '0x8f': null, // 'eip155:143/slip44:268435779', // Monad Mainnet - Native symbol: MON + '0x92': 'eip155:146/slip44:10007', // Sonic Mainnet - Native symbol: S + '0xfa': 'eip155:250/slip44:1007', // Fantom Opera - Native symbol: FTM + '0x141': 'eip155:321/erc20:0x0000000000000000000000000000000000000000', // 'eip155:321/slip44:641', // KCC Mainnet - Native symbol: KCS + '0x144': 'eip155:324/slip44:60', // zkSync Era Mainnet (Ethereum L2) - Native symbol: ETH + '0x169': 'eip155:361/erc20:0x0000000000000000000000000000000000000000', // 'eip155:361/slip44:589', // Theta Mainnet - Native symbol: TFUEL + '0x3e7': 'eip155:999/slip44:2457', // HyperEVM - Native symbol: ETH + '0x440': 'eip155:1088/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // 'eip155:1088/slip44:XXX', // Metis Andromeda Mainnet (Ethereum L2) - Native symbol: METIS + '0x44d': 'eip155:1101/slip44:60', // Polygon zkEVM mainnet - Native symbol: ETH + '0x504': 'eip155:1284/slip44:1284', // Moonbeam - Native symbol: GLMR + '0x505': 'eip155:1285/slip44:1285', // Moonriver - Native symbol: MOVR + '0x531': 'eip155:1329/slip44:19000118', // Sei Mainnet - Native symbol: SEI + '0x1388': 'eip155:5000/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // 'eip155:5000/slip44:XXX', // Mantle - Native symbol: MNT + '0x2105': 'eip155:8453/slip44:60', // Base - Native symbol: ETH + '0x2710': 'eip155:10000/erc20:0x0000000000000000000000000000000000000000', // 'eip155:10000/slip44:145', // Smart Bitcoin Cash - Native symbol: BCH + '0xa4b1': 'eip155:42161/slip44:60', // Arbitrum One - Native symbol: ETH + '0xa4ec': 'eip155:42220/slip44:52752', // Celo Mainnet - Native symbol: CELO + '0xa516': 'eip155:42262/erc20:0x0000000000000000000000000000000000000000', // 'eip155:42262/slip44:474', // Oasis Emerald - Native symbol: ROSE + '0xa86a': 'eip155:43114/slip44:9005', // Avalanche C-Chain - Native symbol: AVAX + '0xe708': 'eip155:59144/slip44:60', // Linea Mainnet - Native symbol: ETH + '0x13c31': 'eip155:81457/erc20:0x0000000000000000000000000000000000000000', // 'eip155:81457/slip44:60', // Blast Mainnet - Native symbol: ETH + '0x17dcd': 'eip155:97741/erc20:0x0000000000000000000000000000000000000000', // 'eip155:97741/slip44:XXX', // Pepe Unchained Mainnet - Native symbol: PEPU + '0x518af': null, // 'eip155:333999/slip44:1997', // Polis Mainnet - Native symbol: POLIS + '0x82750': 'eip155:534352/slip44:60', // Scroll Mainnet - Native symbol: ETH + '0x4e454152': 'eip155:60/slip44:60', // Aurora Mainnet (Ethereum L2 on NEAR) - Native symbol: ETH + '0x63564c40': 'eip155:1666600000/slip44:1023', // Harmony Mainnet Shard 0 - Native symbol: ONE +} as const; + +// MISSING CHAINS WITH NO NATIVE ASSET PRICES IN V2 +// '0x42': 'eip155:66/slip44:996', // OKXChain Mainnet - Native symbol: OKT +// '0x46': 'eip155:70/slip44:1170', // Hoo Smart Chain - Native symbol: HOO +// '0x7a': 'eip155:122/slip44:XXX', // Fuse Mainnet - Native symbol: FUSE +// '0x120': 'eip155:288/slip44:60', // Boba Network (Ethereum L2) - Native symbol: ETH +// '0x150': 'eip155:336/slip44:809', // Shiden - Native symbol: SDN +// '0x28c58': 'eip155:167000/slip44:60', // Taiko Mainnet - Native symbol: ETH + /** * A currency that can be supplied as the `vsCurrency` parameter to * the `/spot-prices` endpoint. Covers both uppercase and lowercase versions. @@ -189,82 +297,9 @@ type SupportedCurrency = * * @see Used by {@link CodefiTokenPricesServiceV2} to validate that a given chain ID is supported by V2 of the Codefi Price API. */ -export const SUPPORTED_CHAIN_IDS = [ - // Ethereum Mainnet - '0x1', - // OP Mainnet - '0xa', - // Cronos Mainnet - '0x19', - // BNB Smart Chain Mainnet - '0x38', - // Syscoin Mainnet - '0x39', - // OKXChain Mainnet - '0x42', - // Hoo Smart Chain - '0x46', - // Meter Mainnet - '0x52', - // TomoChain - '0x58', - // Gnosis - '0x64', - // Velas EVM Mainnet - '0x6a', - // Fuse Mainnet - '0x7a', - // Huobi ECO Chain Mainnet - '0x80', - // Polygon Mainnet - '0x89', - // Fantom Opera - '0xfa', - // Boba Network - '0x120', - // KCC Mainnet - '0x141', - // zkSync Era Mainnet - '0x144', - // Theta Mainnet - '0x169', - // Metis Andromeda Mainnet - '0x440', - // Moonbeam - '0x504', - // Moonriver - '0x505', - // Mantle - '0x1388', - // Base - '0x2105', - // Shiden - '0x150', - // Smart Bitcoin Cash - '0x2710', - // Arbitrum One - '0xa4b1', - // Celo Mainnet - '0xa4ec', - // Oasis Emerald - '0xa516', - // Avalanche C-Chain - '0xa86a', - // Polis Mainnet - '0x518af', - // Aurora Mainnet - '0x4e454152', - // Harmony Mainnet Shard 0 - '0x63564c40', - // Linea Mainnet - '0xe708', - // Sei Mainnet - '0x531', - // Sonic Mainnet - '0x92', - // Monad Mainnet - '0x8f', -] as const; +export const SUPPORTED_CHAIN_IDS = Object.keys( + SPOT_PRICES_SUPPORT_INFO, +) as (keyof typeof SPOT_PRICES_SUPPORT_INFO)[]; /** * A chain ID that can be supplied in the URL for the `/spot-prices` endpoint, @@ -274,98 +309,28 @@ export const SUPPORTED_CHAIN_IDS = [ type SupportedChainId = (typeof SUPPORTED_CHAIN_IDS)[number]; /** - * All requests to V2 of the Price API start with this. + * The list of chain IDs that are supported by V3 of the Codefi Price API. + * Only includes chain IDs from SPOT_PRICES_SUPPORT_INFO that have a non-null CAIP-19 value. */ -const BASE_URL = 'https://price.api.cx.metamask.io/v2'; +const SUPPORTED_CHAIN_IDS_V3 = Object.keys(SPOT_PRICES_SUPPORT_INFO).filter( + (chainId) => + SPOT_PRICES_SUPPORT_INFO[ + chainId as keyof typeof SPOT_PRICES_SUPPORT_INFO + ] !== null, +); const BASE_URL_V1 = 'https://price.api.cx.metamask.io/v1'; -/** - * The shape of the data that the /spot-prices endpoint returns. - */ -type MarketData = { - /** - * The all-time highest price of the token. - */ - allTimeHigh: number; - /** - * The all-time lowest price of the token. - */ - allTimeLow: number; - /** - * The number of tokens currently in circulation. - */ - circulatingSupply: number; - /** - * The market cap calculated using the diluted supply. - */ - dilutedMarketCap: number; - /** - * The highest price of the token in the last 24 hours. - */ - high1d: number; - /** - * The lowest price of the token in the last 24 hours. - */ - low1d: number; - /** - * The current market capitalization of the token. - */ - marketCap: number; - /** - * The percentage change in market capitalization over the last 24 hours. - */ - marketCapPercentChange1d: number; - /** - * The current price of the token. - */ - price: number; - /** - * The absolute change in price over the last 24 hours. - */ - priceChange1d: number; - /** - * The percentage change in price over the last 24 hours. - */ - pricePercentChange1d: number; - /** - * The percentage change in price over the last hour. - */ - pricePercentChange1h: number; - /** - * The percentage change in price over the last year. - */ - pricePercentChange1y: number; - /** - * The percentage change in price over the last 7 days. - */ - pricePercentChange7d: number; - /** - * The percentage change in price over the last 14 days. - */ - pricePercentChange14d: number; - /** - * The percentage change in price over the last 30 days. - */ - pricePercentChange30d: number; - /** - * The percentage change in price over the last 200 days. - */ - pricePercentChange200d: number; - /** - * The total trading volume of the token in the last 24 hours. - */ - totalVolume: number; -}; +const BASE_URL_V2 = 'https://price.api.cx.metamask.io/v2'; + +const BASE_URL_V3 = 'https://price.api.cx.metamask.io/v3'; -type MarketDataByTokenAddress = { [address: Hex]: MarketData }; /** * This version of the token prices service uses V2 of the Codefi Price API to * fetch token prices. */ export class CodefiTokenPricesServiceV2 - implements - AbstractTokenPricesService + implements AbstractTokenPricesService { readonly #policy: ServicePolicy; @@ -474,64 +439,149 @@ export class CodefiTokenPricesServiceV2 * given addresses which are expected to live on the given chain. * * @param args - The arguments to function. - * @param args.chainId - An EIP-155 chain ID. - * @param args.tokenAddresses - Addresses for tokens that live on the chain. + * @param args.assets - The assets to get prices for. * @param args.currency - The desired currency of the token prices. * @returns The prices for the requested tokens. */ async fetchTokenPrices({ - chainId, - tokenAddresses, + assets, currency, }: { - chainId: SupportedChainId; - tokenAddresses: Hex[]; + assets: EvmAssetAddressWithChain[]; currency: SupportedCurrency; - }): Promise>> { - const chainIdAsNumber = hexToNumber(chainId); + }): Promise[]> { + const v3Assets = await this.#fetchTokenPricesV3(assets, currency); + const v2Assets = await this.#fetchTokenPricesV2(assets, currency); - const url = new URL(`${BASE_URL}/chains/${chainIdAsNumber}/spot-prices`); + return [...v3Assets, ...v2Assets]; + } + + async #fetchTokenPricesV3( + assets: EvmAssetAddressWithChain[], + currency: SupportedCurrency, + ): Promise[]> { + const assetsWithIds: EvmAssetWithId[] = assets + // Filter out assets that are not supported by V3 of the Price API. + .filter((asset) => SUPPORTED_CHAIN_IDS_V3.includes(asset.chainId)) + .map((asset) => { + const caipChainId = toCaipChainId( + KnownCaipNamespace.Eip155, + hexToNumber(asset.chainId).toString(), + ); + + const nativeAddress = getNativeTokenAddress(asset.chainId); + + return { + ...asset, + assetId: (nativeAddress.toLowerCase() === + asset.tokenAddress.toLowerCase() + ? SPOT_PRICES_SUPPORT_INFO[asset.chainId] + : `${caipChainId}/erc20:${asset.tokenAddress.toLowerCase()}`) as CaipAssetType, + }; + }) + .filter((asset) => asset.assetId); + + if (assetsWithIds.length === 0) { + return []; + } + + const url = new URL(`${BASE_URL_V3}/spot-prices`); url.searchParams.append( - 'tokenAddresses', - [getNativeTokenAddress(chainId), ...tokenAddresses].join(','), + 'assetIds', + assetsWithIds.map((asset) => asset.assetId).join(','), ); url.searchParams.append('vsCurrency', currency); url.searchParams.append('includeMarketData', 'true'); - const addressCryptoDataMap: MarketDataByTokenAddress = - await this.#policy.execute(() => - handleFetch(url, { headers: { 'Cache-Control': 'no-cache' } }), - ); - - return [getNativeTokenAddress(chainId), ...tokenAddresses].reduce( - ( - obj: Partial>, - tokenAddress, - ) => { - // The Price API lowercases both currency and token addresses, so we have - // to keep track of them and make sure we return the original versions. - const lowercasedTokenAddress = - tokenAddress.toLowerCase() as Lowercase; + const addressCryptoDataMap: { + [assetId: CaipAssetType]: Omit< + MarketDataDetails, + 'currency' | 'tokenAddress' + >; + } = await this.#policy.execute(() => + handleFetch(url, { headers: { 'Cache-Control': 'no-cache' } }), + ); - const marketData = addressCryptoDataMap[lowercasedTokenAddress]; + return assetsWithIds + .map((assetWithId) => { + const marketData = addressCryptoDataMap[assetWithId.assetId]; if (!marketData) { - return obj; + return undefined; } - const token: TokenPrice = { - tokenAddress, - currency, + return { ...marketData, + ...assetWithId, + currency, }; + }) + .filter((entry): entry is NonNullable => Boolean(entry)); + } - return { - ...obj, - [tokenAddress]: token, - }; + async #fetchTokenPricesV2( + assets: EvmAssetAddressWithChain[], + currency: SupportedCurrency, + ): Promise[]> { + const v2SupportedAssets = assets.filter( + (asset) => !SUPPORTED_CHAIN_IDS_V3.includes(asset.chainId), + ); + + const assetsByChainId: Record = + v2SupportedAssets.reduce( + (acc, { chainId, tokenAddress }) => { + (acc[chainId] ??= []).push(tokenAddress); + return acc; + }, + {} as Record, + ); + + const promises = Object.entries(assetsByChainId).map( + async ([chainId, tokenAddresses]) => { + if (tokenAddresses.length === 0) { + return []; + } + + const url = new URL(`${BASE_URL_V2}/chains/${chainId}/spot-prices`); + url.searchParams.append('tokenAddresses', tokenAddresses.join(',')); + url.searchParams.append('vsCurrency', currency); + url.searchParams.append('includeMarketData', 'true'); + + const addressCryptoDataMap: { + [tokenAddress: string]: Omit< + MarketDataDetails, + 'currency' | 'tokenAddress' + >; + } = await this.#policy.execute(() => + handleFetch(url, { headers: { 'Cache-Control': 'no-cache' } }), + ); + + return tokenAddresses + .map((tokenAddress) => { + const marketData = addressCryptoDataMap[tokenAddress.toLowerCase()]; + + if (!marketData) { + return undefined; + } + + return { + ...marketData, + tokenAddress, + chainId: chainId as SupportedChainId, + currency, + }; + }) + .filter((entry): entry is NonNullable => + Boolean(entry), + ); }, - {}, - ) as Partial>; + ); + + return await Promise.allSettled(promises).then((results) => + results.flatMap((result) => + result.status === 'fulfilled' ? result.value : [], + ), + ); } /** diff --git a/packages/assets-controllers/src/token-service.test.ts b/packages/assets-controllers/src/token-service.test.ts index df2feaaf5e7..7fd1afcc882 100644 --- a/packages/assets-controllers/src/token-service.test.ts +++ b/packages/assets-controllers/src/token-service.test.ts @@ -499,7 +499,7 @@ describe('Token service', () => { nock(TOKEN_END_POINT_API) .get( - `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10&includeMarketData=false`, ) .reply(200, mockResponse) .persist(); @@ -523,7 +523,7 @@ describe('Token service', () => { nock(TOKEN_END_POINT_API) .get( - `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=${customLimit}`, + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=${customLimit}&includeMarketData=false`, ) .reply(200, mockResponse) .persist(); @@ -549,7 +549,7 @@ describe('Token service', () => { nock(TOKEN_END_POINT_API) .get( - `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${encodedQuery}&limit=10`, + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${encodedQuery}&limit=10&includeMarketData=false`, ) .reply(200, mockResponse) .persist(); @@ -575,7 +575,7 @@ describe('Token service', () => { nock(TOKEN_END_POINT_API) .get( - `/tokens/search?chainIds=${encodedChainIds}&query=${searchQuery}&limit=10`, + `/tokens/search?networks=${encodedChainIds}&query=${searchQuery}&limit=10&includeMarketData=false`, ) .reply(200, mockResponse) .persist(); @@ -595,7 +595,7 @@ describe('Token service', () => { const searchQuery = 'USD'; nock(TOKEN_END_POINT_API) .get( - `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10&includeMarketData=false`, ) .replyWithError('Example network error') .persist(); @@ -609,7 +609,7 @@ describe('Token service', () => { const searchQuery = 'USD'; nock(TOKEN_END_POINT_API) .get( - `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10&includeMarketData=false`, ) .reply(400, { error: 'Bad Request' }) .persist(); @@ -623,7 +623,7 @@ describe('Token service', () => { const searchQuery = 'USD'; nock(TOKEN_END_POINT_API) .get( - `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10&includeMarketData=false`, ) .reply(500) .persist(); @@ -643,7 +643,7 @@ describe('Token service', () => { nock(TOKEN_END_POINT_API) .get( - `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10&includeMarketData=false`, ) .reply(200, mockResponse) .persist(); @@ -662,7 +662,9 @@ describe('Token service', () => { }; nock(TOKEN_END_POINT_API) - .get(`/tokens/search?chainIds=&query=${searchQuery}&limit=10`) + .get( + `/tokens/search?networks=&query=${searchQuery}&limit=10&includeMarketData=false`, + ) .reply(200, mockResponse) .persist(); @@ -676,7 +678,7 @@ describe('Token service', () => { const errorResponse = { error: 'Invalid search query' }; nock(TOKEN_END_POINT_API) .get( - `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10&includeMarketData=false`, ) .reply(200, errorResponse) .persist(); @@ -709,7 +711,7 @@ describe('Token service', () => { nock(TOKEN_END_POINT_API) .get( - `/tokens/search?chainIds=${encodedChainIds}&query=${searchQuery}&limit=10`, + `/tokens/search?networks=${encodedChainIds}&query=${searchQuery}&limit=10&includeMarketData=false`, ) .reply(200, mockResponse) .persist(); @@ -721,6 +723,31 @@ describe('Token service', () => { data: sampleSearchResults, }); }); + + it('should include market data when includeMarketData is true', async () => { + const searchQuery = 'USD'; + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10&includeMarketData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery, { + includeMarketData: true, + }); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + }); + }); }); describe('getTrendingTokens', () => { @@ -783,7 +810,7 @@ describe('Token service', () => { const testMaxMarketCap = 1000000; nock(TOKEN_END_POINT_API) .get( - `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&sortBy=${sortBy}&minLiquidity=${testMinLiquidity}&minVolume24hUsd=${testMinVolume24hUsd}&maxVolume24hUsd=${testMaxVolume24hUsd}&minMarketCap=${testMinMarketCap}&maxMarketCap=${testMaxMarketCap}`, + `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&sort=${sortBy}&minLiquidity=${testMinLiquidity}&minVolume24hUsd=${testMinVolume24hUsd}&maxVolume24hUsd=${testMaxVolume24hUsd}&minMarketCap=${testMinMarketCap}&maxMarketCap=${testMaxMarketCap}`, ) .reply(200, sampleTrendingTokens) .persist(); diff --git a/packages/assets-controllers/src/token-service.ts b/packages/assets-controllers/src/token-service.ts index e084f92f361..6d94dcabce7 100644 --- a/packages/assets-controllers/src/token-service.ts +++ b/packages/assets-controllers/src/token-service.ts @@ -54,14 +54,20 @@ export type SortTrendingBy = * @param chainIds - Array of CAIP format chain IDs (e.g., 'eip155:1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'). * @param query - The search query (token name, symbol, or address). * @param limit - Optional limit for the number of results (defaults to 10). + * @param includeMarketData - Optional flag to include market data in the results (defaults to false). * @returns The token search URL. */ -function getTokenSearchURL(chainIds: CaipChainId[], query: string, limit = 10) { +function getTokenSearchURL( + chainIds: CaipChainId[], + query: string, + limit = 10, + includeMarketData = false, +) { const encodedQuery = encodeURIComponent(query); const encodedChainIds = chainIds .map((id) => encodeURIComponent(id)) .join(','); - return `${TOKEN_END_POINT_API}/tokens/search?chainIds=${encodedChainIds}&query=${encodedQuery}&limit=${limit}`; + return `${TOKEN_END_POINT_API}/tokens/search?networks=${encodedChainIds}&query=${encodedQuery}&limit=${limit}&includeMarketData=${includeMarketData}`; } /** @@ -69,7 +75,7 @@ function getTokenSearchURL(chainIds: CaipChainId[], query: string, limit = 10) { * * @param options - Options for getting trending tokens. * @param options.chainIds - Array of CAIP format chain IDs (e.g., ['eip155:1', 'eip155:137', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp']). - * @param options.sortBy - The sort by field. + * @param options.sort - The sort field. * @param options.minLiquidity - The minimum liquidity. * @param options.minVolume24hUsd - The minimum volume 24h in USD. * @param options.maxVolume24hUsd - The maximum volume 24h in USD. @@ -79,7 +85,7 @@ function getTokenSearchURL(chainIds: CaipChainId[], query: string, limit = 10) { */ function getTrendingTokensURL(options: { chainIds: CaipChainId[]; - sortBy?: SortTrendingBy; + sort?: SortTrendingBy; minLiquidity?: number; minVolume24hUsd?: number; maxVolume24hUsd?: number; @@ -144,14 +150,20 @@ export async function fetchTokenListByChainId( * @param query - The search query (token name, symbol, or address). * @param options - Additional fetch options. * @param options.limit - The maximum number of results to return. + * @param options.includeMarketData - Optional flag to include market data in the results (defaults to false). * @returns Object containing count and data array. Returns { count: 0, data: [] } if request fails. */ export async function searchTokens( chainIds: CaipChainId[], query: string, - { limit = 10 } = {}, + { limit = 10, includeMarketData = false } = {}, ): Promise<{ count: number; data: unknown[] }> { - const tokenSearchURL = getTokenSearchURL(chainIds, query, limit); + const tokenSearchURL = getTokenSearchURL( + chainIds, + query, + limit, + includeMarketData, + ); try { const result = await handleFetch(tokenSearchURL); @@ -233,7 +245,7 @@ export async function getTrendingTokens({ const trendingTokensURL = getTrendingTokensURL({ chainIds, - sortBy, + sort: sortBy, minLiquidity, minVolume24hUsd, maxVolume24hUsd, diff --git a/packages/base-controller/src/BaseController.test.ts b/packages/base-controller/src/BaseController.test.ts index 954d3277030..70d394cf628 100644 --- a/packages/base-controller/src/BaseController.test.ts +++ b/packages/base-controller/src/BaseController.test.ts @@ -1,9 +1,6 @@ /* eslint-disable jest/no-export */ -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MockAnyNamespace, -} from '@metamask/messenger'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { MockAnyNamespace } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; import type { Draft, Patch } from 'immer'; import * as sinon from 'sinon'; diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 4e267466625..bd7277c11ae 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/assets-controllers` from `^93.0.0` to `^93.1.0` ([#7309](https://github.com/MetaMask/core/pull/7309) +- Bump `@metamask/remote-feature-flag-controller` from `^2.0.1` to `^3.0.0` ([#7309](https://github.com/MetaMask/core/pull/7309) +- Bump `@metamask/transaction-controller` from `^62.4.0` to `^62.5.0` ([#7325](https://github.com/MetaMask/core/pull/7325)) + +### Fixed + +- Update gas calculation logic to use the priority fee provided by the gas-api and stop adding the base fee ([#7403](https://github.com/MetaMask/core/pull/7403)) + +## [64.0.0] + +### Added + +- Port `fetchTokens` and `type SwapsToken` from `@metamask/swaps-controller` and export them to allow deprecating `swaps-controller` while still supporting downstream consumers ([#7278](https://github.com/MetaMask/core/pull/7278)) +- Handle edge case in which approvals fail if an EVM account has an insufficient non-zero USDT allowance on mainnet ([#7228](https://github.com/MetaMask/core/pull/7228)) + - Set quoteRequest `resetApproval` parameter by calculating the wallet's USDT allowance on mainnet for the swap or bridge spender + - When a valid quote is received, append the `resetApproval` trade data to set the wallet's USDT allowance to `0` + - Include the `resetApproval` tx in network fee calculations + +### Changed + +- **BREAKING:** Remove `SWAPS_TESTNET_CHAIN_ID` export and use `CHAIN_IDS.LOCALHOST` instead ([#7278](https://github.com/MetaMask/core/pull/7278)) +- Bump `@metamask/network-controller` from `^26.0.0` to `^27.0.0` ([#7258](https://github.com/MetaMask/core/pull/7258)) +- Bump `@metamask/transaction-controller` from `^62.3.0` to `^62.4.0` ([#7257](https://github.com/MetaMask/core/pull/7257), [#7289](https://github.com/MetaMask/core/pull/7289)) +- Bump `@metamask/assets-controllers` from `^92.0.0` to `^93.0.0` ([#7291](https://github.com/MetaMask/core/pull/7291)) + +### Removed + +- **BREAKING** Remove public `getBridgeERC20Allowance` action to prevent consumers from using it. This handler is only applicable to Swap and Bridge txs involving USDT on mainnet ([#7228](https://github.com/MetaMask/core/pull/7228)) + +### Fixed + +- **BREAKING:** Add `usd_amount_source` to QuotesRequested event properties. Clients will need to add this value to the quoteRequest context ([#7294](https://github.com/MetaMask/core/pull/7294)) +- Add missing MON (Monad) and SEI (Sei) to integer chain IDs ([#7252](https://github.com/MetaMask/core/pull/7252)) + +## [63.2.0] + +### Changed + +- Update `stopPollingForQuotes` to accept metrics context for the QuotesReceived event. If context is provided and quotes are still loading when the handler is called, the `Unified SwapBridge Quotes Received` is published before the poll is cancelled ([#7242](https://github.com/MetaMask/core/pull/7242)) + +## [63.1.0] + +### Added + +- Port the following constants from `SwapsController` and export them: `SWAPS_TESTNET_CHAIN_ID`, `SWAPS_CONTRACT_ADDRESSES`, `SWAPS_WRAPPED_TOKENS_ADDRESSES`, `ALLOWED_CONTRACT_ADDRESSES` ([#7233](https://github.com/MetaMask/core/pull/7233)) +- Port the following utils from `SwapsController` and export them: `isValidSwapsContractAddress`, `getSwapsContractAddress` ([#7233](https://github.com/MetaMask/core/pull/7233)) + +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7220](https://github.com/MetaMask/core/pull/7220), [#7236](https://github.com/MetaMask/core/pull/7236)) + - The dependencies moved are: + - `@metamask/accounts-controller` (^35.0.0) + - `@metamask/assets-controllers` (^91.0.0) + - `@metamask/network-controller` (^26.0.0) + - `@metamask/remote-feature-flag-controller` (^2.0.1) + - `@metamask/snaps-controllers` (^14.0.0) + - `@metamask/transaction-controller` (^62.3.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + +### Fixed + +- Update `quotesLoadingStatus` to "LOADING" if a balance fetch is needed before fetching quotes ((https://github.com/MetaMask/core/pull/7227)[#7227]) +- Wait for async SSE message handlers before updating `quotesLoadingStatus` to prevent clients from displaying "No quotes" warnings ((https://github.com/MetaMask/core/pull/7227)[#7227]) + +## [63.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controllers` from `^90.0.0` to `^91.0.0` ([#7207](https://github.com/MetaMask/core/pull/7207)) + ## [62.0.0] ### Added @@ -864,7 +938,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@62.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@64.0.0...HEAD +[64.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@63.2.0...@metamask/bridge-controller@64.0.0 +[63.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@63.1.0...@metamask/bridge-controller@63.2.0 +[63.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@63.0.0...@metamask/bridge-controller@63.1.0 +[63.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@62.0.0...@metamask/bridge-controller@63.0.0 [62.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@61.0.0...@metamask/bridge-controller@62.0.0 [61.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@60.1.0...@metamask/bridge-controller@61.0.0 [60.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@60.0.0...@metamask/bridge-controller@60.1.0 diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 749b5663585..0334b0bc6ef 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-controller", - "version": "62.0.0", + "version": "64.0.0", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "MetaMask", @@ -53,6 +53,8 @@ "@ethersproject/constants": "^5.7.0", "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", + "@metamask/accounts-controller": "^35.0.0", + "@metamask/assets-controllers": "^93.1.0", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.16.0", "@metamask/gas-fee-controller": "^26.0.0", @@ -60,22 +62,20 @@ "@metamask/messenger": "^0.3.0", "@metamask/metamask-eth-abis": "^3.1.1", "@metamask/multichain-network-controller": "^3.0.0", + "@metamask/network-controller": "^27.0.0", "@metamask/polling-controller": "^16.0.0", + "@metamask/remote-feature-flag-controller": "^3.0.0", + "@metamask/snaps-controllers": "^14.0.1", + "@metamask/transaction-controller": "^62.5.0", "@metamask/utils": "^11.8.1", "bignumber.js": "^9.1.2", "reselect": "^5.1.1", "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/accounts-controller": "^35.0.0", - "@metamask/assets-controllers": "^90.0.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-json-rpc-provider": "^6.0.0", - "@metamask/network-controller": "^26.0.0", - "@metamask/remote-feature-flag-controller": "^2.0.1", - "@metamask/snaps-controllers": "^14.0.1", "@metamask/superstruct": "^3.1.0", - "@metamask/transaction-controller": "^62.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -88,14 +88,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/accounts-controller": "^35.0.0", - "@metamask/assets-controllers": "^90.0.0", - "@metamask/network-controller": "^26.0.0", - "@metamask/remote-feature-flag-controller": "^2.0.0", - "@metamask/snaps-controllers": "^14.0.0", - "@metamask/transaction-controller": "^62.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap index 6d2e89aefdb..aaf5643b16e 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap @@ -99,6 +99,7 @@ Array [ "token_address_source": "eip155:1/slip44:60", "token_symbol_destination": "USDC", "token_symbol_source": "ETH", + "usd_amount_source": 100, }, ], ] @@ -167,6 +168,7 @@ Object { "destTokenAddress": "123d1", "destWalletAddress": "SolanaWalletAddres1234", "insufficientBal": false, + "resetApproval": false, "slippage": 0.5, "srcChainId": "0x1", "srcTokenAddress": "0x0000000000000000000000000000000000000000", @@ -275,6 +277,7 @@ Object { "destTokenAddress": "123d1", "destWalletAddress": "SolanaWalletAddres1234", "insufficientBal": false, + "resetApproval": false, "slippage": 0.5, "srcChainId": "0x1", "srcTokenAddress": "0x0000000000000000000000000000000000000000", diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap index 06d48ac4790..b2dd8b7993b 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap @@ -14,6 +14,7 @@ Object { "destChainId": "0x1", "destTokenAddress": "0x0000000000000000000000000000000000000000", "insufficientBal": false, + "resetApproval": false, "srcChainId": "0xa", "srcTokenAddress": "0x4200000000000000000000000000000000000006", "srcTokenAmount": "991250000000000000", @@ -39,6 +40,7 @@ Object { "destChainId": "0x1", "destTokenAddress": "0x0000000000000000000000000000000000000000", "insufficientBal": false, + "resetApproval": false, "srcChainId": "0xa", "srcTokenAddress": "0x4200000000000000000000000000000000000006", "srcTokenAmount": "991250000000000000", @@ -603,16 +605,24 @@ Array [ Array [ "SnapController:handleRequest", Object { - "handler": "onClientRequest", + "handler": "onProtocolRequest", "origin": "metamask", "request": Object { - "id": "test-uuid-1234", "jsonrpc": "2.0", - "method": "computeFee", + "method": " ", "params": Object { - "accountId": "account1", + "request": Object { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "getMinimumBalanceForRentExemption", + "params": Array [ + 0, + Object { + "commitment": "confirmed", + }, + ], + }, "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", }, }, "snapId": "npm:@metamask/solana-snap", @@ -630,7 +640,7 @@ Array [ "params": Object { "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAIEnLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHz7U6VQBhniAZG564p5JhG+y5+5uEABjxPtimE61bsqsz4TFeaDdmFmlW16xBf2qhUAUla7cIQjqp3HfLznM1aZqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVZ0EED+QHqrBQRqB+cbMfYZjXZcTe9r+CfdbguirL8P49t1pWG6qWtPmFmciR1xbrt4IW+b1nNcz2N5abYbCcsDgByJFz/oyJeNAhYJfn7erTZs6xJHjnuAV0v/cuH6iQNCzB1ajK9lOERjgtFNI8XDODau1kgDlDaRIGFfFNP09KMWgsU3Ye36HzgEdq38sqvZDFOifcDzPxfPOcDxeZgLShtMST0fB39lSGQI7f01fZv+JVg5S4qIF2zdmCAhSAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAACMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKkEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTj1E+LF26QsO9gzDavYNO6ZflUDWJ+gBV9eCQ5OcuzAMStD/6J/XX9kp0wJsfKVh53ksJqzbfyd1RSzIap7OM5egJanTpAxnCBLW4j9Mn+DAuluhVY4cEgRJ9Pah1VqYQXzWdRJXp28EMpR0GPlVtcnRtTGlBHjaRvhFYLMMzzMD6CQoABQLAXBUACgAJA0ANAwAAAAAACwYAAQIbDA0ACwYAAwAcDA0BAQwCAAMMAgAAAFBGFTsAAAAADQEDAREOKQ0PAAMEBQEcGw4OEA4dDx4SBAYTFBUNBxYICQ4fDwYFFxgZGiAhIiMNKMEgmzNB1pyBAwIAAAAaZAABOGQBAlBGFTsAAAAAP4hnBwAAAABkAAANAwMAAAEJEQUAAgEbDLwBj+v8wtNahk0AAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOCjcXQcAAAAAAAAAAAAAAACUXhgAAAAAABb1AwAAAAAAGABuuH/gY8j1t421m3ekiET/qFVeKhVA3SJVS5OH/NW+oQMAAAAAAAAAAAAAAABCAAAAAAAAAAAAAAAAAAAAAAAAQrPV80YDAAAACwLaZwAAAAAAAAAAAAAAAAAAAAClqm4hcbQW4dJ+xTyowT2z+RqJzQADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAARE9whapJMxiYg1Y/S9bROWrjXfldZCFcyME/snbeFkkhAUXFisYKQMaKiVZfTkrqqg0GkW+iGFAaIHEbhkRX4YCBLoWvHI1OH2T2gSmTlKhBREUDA0H", + "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", }, }, "snapId": "npm:@metamask/solana-snap", @@ -639,24 +649,16 @@ Array [ Array [ "SnapController:handleRequest", Object { - "handler": "onProtocolRequest", + "handler": "onClientRequest", "origin": "metamask", "request": Object { + "id": "test-uuid-1234", "jsonrpc": "2.0", - "method": " ", + "method": "computeFee", "params": Object { - "request": Object { - "id": "test-uuid-1234", - "jsonrpc": "2.0", - "method": "getMinimumBalanceForRentExemption", - "params": Array [ - 0, - Object { - "commitment": "confirmed", - }, - ], - }, + "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAIEnLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHz7U6VQBhniAZG564p5JhG+y5+5uEABjxPtimE61bsqsz4TFeaDdmFmlW16xBf2qhUAUla7cIQjqp3HfLznM1aZqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVZ0EED+QHqrBQRqB+cbMfYZjXZcTe9r+CfdbguirL8P49t1pWG6qWtPmFmciR1xbrt4IW+b1nNcz2N5abYbCcsDgByJFz/oyJeNAhYJfn7erTZs6xJHjnuAV0v/cuH6iQNCzB1ajK9lOERjgtFNI8XDODau1kgDlDaRIGFfFNP09KMWgsU3Ye36HzgEdq38sqvZDFOifcDzPxfPOcDxeZgLShtMST0fB39lSGQI7f01fZv+JVg5S4qIF2zdmCAhSAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAACMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKkEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTj1E+LF26QsO9gzDavYNO6ZflUDWJ+gBV9eCQ5OcuzAMStD/6J/XX9kp0wJsfKVh53ksJqzbfyd1RSzIap7OM5egJanTpAxnCBLW4j9Mn+DAuluhVY4cEgRJ9Pah1VqYQXzWdRJXp28EMpR0GPlVtcnRtTGlBHjaRvhFYLMMzzMD6CQoABQLAXBUACgAJA0ANAwAAAAAACwYAAQIbDA0ACwYAAwAcDA0BAQwCAAMMAgAAAFBGFTsAAAAADQEDAREOKQ0PAAMEBQEcGw4OEA4dDx4SBAYTFBUNBxYICQ4fDwYFFxgZGiAhIiMNKMEgmzNB1pyBAwIAAAAaZAABOGQBAlBGFTsAAAAAP4hnBwAAAABkAAANAwMAAAEJEQUAAgEbDLwBj+v8wtNahk0AAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOCjcXQcAAAAAAAAAAAAAAACUXhgAAAAAABb1AwAAAAAAGABuuH/gY8j1t421m3ekiET/qFVeKhVA3SJVS5OH/NW+oQMAAAAAAAAAAAAAAABCAAAAAAAAAAAAAAAAAAAAAAAAQrPV80YDAAAACwLaZwAAAAAAAAAAAAAAAAAAAAClqm4hcbQW4dJ+xTyowT2z+RqJzQADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAARE9whapJMxiYg1Y/S9bROWrjXfldZCFcyME/snbeFkkhAUXFisYKQMaKiVZfTkrqqg0GkW+iGFAaIHEbhkRX4YCBLoWvHI1OH2T2gSmTlKhBREUDA0H", }, }, "snapId": "npm:@metamask/solana-snap", @@ -787,7 +789,7 @@ Object { "quotes": Array [], "quotesInitialLoadTime": null, "quotesLastFetched": null, - "quotesLoadingStatus": null, + "quotesLoadingStatus": 0, "quotesRefreshCount": 0, } `; @@ -807,13 +809,14 @@ Object { "destTokenAddress": "123d1", "destWalletAddress": "SolanaWalletAddres1234", "insufficientBal": false, + "resetApproval": false, "slippage": 0.5, "srcChainId": "0x1", "srcTokenAddress": "0x0000000000000000000000000000000000000000", "srcTokenAmount": "10", "walletAddress": "0x123", }, - "quotesInitialLoadTime": 15000, + "quotesInitialLoadTime": 10000, "quotesLoadingStatus": 1, "quotesRefreshCount": 1, } @@ -955,6 +958,7 @@ Array [ "token_address_source": "eip155:1/slip44:60", "token_symbol_destination": "USDC", "token_symbol_source": "ETH", + "usd_amount_source": 100, }, ], ] diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index 75520fe174b..37912d87b07 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -1,21 +1,23 @@ +import { BigNumber } from '@ethersproject/bignumber'; +import * as ethersContractUtils from '@ethersproject/contracts'; import { SolScope } from '@metamask/keyring-api'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; import { BridgeController } from './bridge-controller'; import { BridgeClientId, BRIDGE_PROD_API_BASE_URL, DEFAULT_BRIDGE_CONTROLLER_STATE, + ETH_USDT_ADDRESS, } from './constants/bridge'; -import type { QuoteResponse, TxData } from './types'; -import { - ChainId, - RequestStatus, - type BridgeControllerMessenger, -} from './types'; +import { ChainId, RequestStatus } from './types'; +import type { BridgeControllerMessenger, QuoteResponse, TxData } from './types'; import * as balanceUtils from './utils/balance'; +import { formatChainIdToDec } from './utils/caip-formatters'; import * as featureFlagUtils from './utils/feature-flags'; import * as fetchUtils from './utils/fetch'; import { flushPromises } from '../../../tests/helpers'; +import mockBridgeQuotesErc20Erc20 from '../tests/mock-quotes-erc20-erc20.json'; import mockBridgeQuotesNativeErc20Eth from '../tests/mock-quotes-native-erc20-eth.json'; import mockBridgeQuotesNativeErc20 from '../tests/mock-quotes-native-erc20.json'; import { @@ -40,6 +42,7 @@ const quoteRequest = { slippage: 0.5, walletAddress: '0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294', destWalletAddress: 'SolanaWalletAddres1234', + resetApproval: false, }; const metricsContext = { token_symbol_source: 'ETH', @@ -153,6 +156,7 @@ describe('BridgeController SSE', function () { updatedQuoteRequest: { ...quoteRequest, insufficientBal: false, + resetApproval: false, }, context: metricsContext, }); @@ -161,6 +165,7 @@ describe('BridgeController SSE', function () { ...DEFAULT_BRIDGE_CONTROLLER_STATE, quoteRequest, assetExchangeRates, + quotesLoadingStatus: RequestStatus.LOADING, }; expect(bridgeController.state).toStrictEqual(expectedState); @@ -171,6 +176,7 @@ describe('BridgeController SSE', function () { { ...quoteRequest, insufficientBal: false, + resetApproval: false, }, expect.any(AbortSignal), BridgeClientId.EXTENSION, @@ -194,10 +200,15 @@ describe('BridgeController SSE', function () { expect(bridgeController.state).toStrictEqual({ ...expectedState, quotesInitialLoadTime: 6000, - quoteRequest: { ...quoteRequest, insufficientBal: false }, + quoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, quotes: mockBridgeQuotesNativeErc20.map((quote) => ({ ...quote, l1GasFeesInHexWei: '0x1', + resetApproval: undefined, })), quotesRefreshCount: 1, quotesLoadingStatus: 1, @@ -211,6 +222,268 @@ describe('BridgeController SSE', function () { expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }); + it.each([ + [ + 'swapping', + '1', + '0x1', + '0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000', + ], + [ + 'bridging', + '1', + SolScope.Mainnet, + '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000000000', + ], + ['swapping', '0', '0x1', undefined, false, 1], + ])( + 'should append resetApproval when %s USDT on Ethereum', + async function ( + _: string, + allowance: string, + destChainId: string, + tradeData?: string, + resetApproval: boolean = true, + mockContractCalls: number = 3, + srcTokenAddress: string = ETH_USDT_ADDRESS, + ) { + const mockUSDTQuoteResponse = mockBridgeQuotesErc20Erc20.map((quote) => ({ + ...quote, + quote: { + ...quote.quote, + srcTokenAddress, + srcChainId: 1, + destChainId: formatChainIdToDec(destChainId), + }, + })); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSource(mockUSDTQuoteResponse as QuoteResponse[]); + }); + + const contractMock = new ethersContractUtils.Contract( + ETH_USDT_ADDRESS, + abiERC20, + ); + const contractMockSpy = jest + .spyOn(ethersContractUtils, 'Contract') + .mockImplementation(() => { + return { + ...jest.requireActual('@ethersproject/contracts').Contract, + interface: contractMock.interface, + allowance: jest.fn().mockResolvedValue(BigNumber.from(allowance)), + }; + }); + + const usdtQuoteRequest = { + ...quoteRequest, + srcTokenAddress, + srcChainId: '0x1', + destChainId, + }; + + await bridgeController.updateBridgeQuoteRequestParams( + usdtQuoteRequest, + metricsContext, + ); + + // Before polling starts + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + updatedQuoteRequest: { + ...usdtQuoteRequest, + insufficientBal: false, + resetApproval, + }, + context: metricsContext, + }); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quoteRequest: usdtQuoteRequest, + assetExchangeRates, + quotesLoadingStatus: RequestStatus.LOADING, + }; + expect(bridgeController.state).toStrictEqual(expectedState); + + // Loading state + jest.advanceTimersByTime(1000); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + mockFetchFn, + { + ...usdtQuoteRequest, + insufficientBal: false, + resetApproval, + }, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + BRIDGE_PROD_API_BASE_URL, + { + onValidationFailure: expect.any(Function), + onValidQuoteReceived: expect.any(Function), + onClose: expect.any(Function), + }, + '13.8.0', + ); + const { quotesLastFetched: t1, quoteRequest: stateQuoteRequest } = + bridgeController.state; + expect(stateQuoteRequest).toStrictEqual({ + ...usdtQuoteRequest, + insufficientBal: false, + resetApproval, + }); + expect(t1).toBeCloseTo(Date.now() - 1000); + + // After first fetch + jest.advanceTimersByTime(5000); + await flushPromises(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesInitialLoadTime: 6000, + quoteRequest: { + ...usdtQuoteRequest, + insufficientBal: false, + resetApproval, + }, + quotes: mockUSDTQuoteResponse.map((quote) => ({ + ...quote, + resetApproval: tradeData + ? { + ...quote.approval, + data: tradeData, + } + : undefined, + })), + quotesRefreshCount: 1, + quotesLoadingStatus: 1, + quotesLastFetched: t1, + }); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledTimes(0); + expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); + expect(contractMockSpy.mock.calls).toHaveLength(mockContractCalls); + }, + ); + + it('should use resetApproval and insufficientBal fallback values if provider is not found', async function () { + messengerMock.call.mockReturnValue({ + address: '0x123', + provider: undefined, + currencyRates: {}, + marketData: {}, + conversionRates: {}, + } as never); + const mockUSDTQuoteResponse = mockBridgeQuotesErc20Erc20.map((quote) => ({ + ...quote, + quote: { + ...quote.quote, + srcTokenAddress: ETH_USDT_ADDRESS, + srcChainId: 1, + }, + })); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSource(mockUSDTQuoteResponse as QuoteResponse[]); + }); + + const contractMock = new ethersContractUtils.Contract( + ETH_USDT_ADDRESS, + abiERC20, + ); + const contractMockSpy = jest + .spyOn(ethersContractUtils, 'Contract') + .mockImplementation(() => { + return { + ...jest.requireActual('@ethersproject/contracts').Contract, + interface: contractMock.interface, + allowance: jest.fn().mockResolvedValue(BigNumber.from('1')), + }; + }); + + const usdtQuoteRequest = { + ...quoteRequest, + srcTokenAddress: ETH_USDT_ADDRESS, + srcChainId: '0x1', + }; + + await bridgeController.updateBridgeQuoteRequestParams( + usdtQuoteRequest, + metricsContext, + ); + + // Before polling starts + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + updatedQuoteRequest: { + ...usdtQuoteRequest, + insufficientBal: true, + resetApproval: true, + }, + context: metricsContext, + }); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quoteRequest: usdtQuoteRequest, + assetExchangeRates, + quotesLoadingStatus: RequestStatus.LOADING, + }; + expect(bridgeController.state).toStrictEqual(expectedState); + + // Loading state + jest.advanceTimersByTime(1000); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + mockFetchFn, + { + ...usdtQuoteRequest, + insufficientBal: true, + resetApproval: true, + }, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + BRIDGE_PROD_API_BASE_URL, + { + onValidationFailure: expect.any(Function), + onValidQuoteReceived: expect.any(Function), + onClose: expect.any(Function), + }, + '13.8.0', + ); + const { quotesLastFetched: t1, quoteRequest: stateQuoteRequest } = + bridgeController.state; + expect(stateQuoteRequest).toStrictEqual({ + ...usdtQuoteRequest, + insufficientBal: true, + resetApproval: true, + }); + expect(t1).toBeCloseTo(Date.now() - 1000); + + // After first fetch + jest.advanceTimersByTime(5000); + await flushPromises(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesInitialLoadTime: 6000, + quoteRequest: { + ...usdtQuoteRequest, + insufficientBal: true, + resetApproval: true, + }, + quotes: mockUSDTQuoteResponse.map((quote) => ({ + ...quote, + resetApproval: { + ...quote.approval, + data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000000000', + }, + })), + quotesRefreshCount: 1, + quotesLoadingStatus: 1, + quotesLastFetched: t1, + }); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledTimes(0); + expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); + expect(contractMockSpy.mock.calls).toHaveLength(2); + }); + it('should replace all stale quotes after a refresh and first quote is received', async function () { mockFetchFn.mockImplementationOnce(async () => { return mockSseEventSource( @@ -235,6 +508,7 @@ describe('BridgeController SSE', function () { mockBridgeQuotesNativeErc20.map((quote) => ({ ...quote, l1GasFeesInHexWei: '0x1', + resetApproval: undefined, })), ); const t1 = bridgeController.state.quotesLastFetched; @@ -248,8 +522,15 @@ describe('BridgeController SSE', function () { const expectedState = { ...DEFAULT_BRIDGE_CONTROLLER_STATE, quotesInitialLoadTime: FIRST_FETCH_DELAY, - quoteRequest: { ...quoteRequest, insufficientBal: false }, - quotes: [mockBridgeQuotesNativeErc20Eth[0]], + quoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + quotes: [mockBridgeQuotesNativeErc20Eth[0]].map((quote) => ({ + ...quote, + resetApproval: undefined, + })), quotesLoadingStatus: RequestStatus.LOADING, quotesRefreshCount: 1, assetExchangeRates, @@ -272,7 +553,10 @@ describe('BridgeController SSE', function () { await advanceToNthTimerThenFlush(); expect(bridgeController.state).toStrictEqual({ ...expectedState, - quotes: mockBridgeQuotesNativeErc20Eth, + quotes: mockBridgeQuotesNativeErc20Eth.map((quote) => ({ + ...quote, + resetApproval: undefined, + })), quotesLastFetched: t2, quotesRefreshCount: 2, quotesLoadingStatus: RequestStatus.FETCHED, @@ -323,7 +607,10 @@ describe('BridgeController SSE', function () { FIRST_FETCH_DELAY, ); expect(bridgeController.state.quotes).toStrictEqual( - mockBridgeQuotesNativeErc20Eth, + mockBridgeQuotesNativeErc20Eth.map((quote) => ({ + ...quote, + resetApproval: undefined, + })), ); const t2 = bridgeController.state.quotesLastFetched; @@ -333,7 +620,11 @@ describe('BridgeController SSE', function () { expect(bridgeController.state).toStrictEqual({ ...DEFAULT_BRIDGE_CONTROLLER_STATE, quotesInitialLoadTime: FIRST_FETCH_DELAY, - quoteRequest: { ...quoteRequest, insufficientBal: false }, + quoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, quotes: [], quotesLoadingStatus: 2, quoteFetchError: 'Network error', @@ -418,6 +709,7 @@ describe('BridgeController SSE', function () { expect(t5).toBeGreaterThan(t2!); const expectedState = { ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quotesLoadingStatus: RequestStatus.LOADING, quoteRequest: { ...quoteRequest, srcTokenAmount: '10', @@ -432,6 +724,7 @@ describe('BridgeController SSE', function () { token_symbol_source: 'ETH', token_symbol_destination: 'USDC', security_warnings: [], + usd_amount_source: 100, }, ); // Right after state update, before fetch has started @@ -443,6 +736,7 @@ describe('BridgeController SSE', function () { ...quoteRequest, srcTokenAmount: '10', insufficientBal: true, + resetApproval: false, }, quotesLastFetched: Date.now(), quotesLoadingStatus: RequestStatus.LOADING, @@ -453,13 +747,20 @@ describe('BridgeController SSE', function () { const expectedStateAfterFirstQuote = { ...expectedState, quotesInitialLoadTime: THIRD_FETCH_DELAY, - quotes: [{ ...mockBridgeQuotesNativeErc20[0], l1GasFeesInHexWei: '0x1' }], + quotes: [ + { + ...mockBridgeQuotesNativeErc20[0], + l1GasFeesInHexWei: '0x1', + resetApproval: undefined, + }, + ], quotesRefreshCount: 0, quotesLoadingStatus: RequestStatus.LOADING, quoteRequest: { ...quoteRequest, srcTokenAmount: '10', insufficientBal: true, + resetApproval: false, }, quotesLastFetched: t1, }; @@ -484,6 +785,7 @@ describe('BridgeController SSE', function () { ].map((quote) => ({ ...quote, l1GasFeesInHexWei: '0x1', + resetApproval: undefined, })), }); expect( @@ -577,6 +879,7 @@ describe('BridgeController SSE', function () { token_symbol_source: 'ETH', token_symbol_destination: 'USDC', security_warnings: [], + usd_amount_source: 100, }, ); @@ -600,6 +903,7 @@ describe('BridgeController SSE', function () { (quote) => ({ ...quote, l1GasFeesInHexWei: '0x1', + resetApproval: undefined, }), ), ); @@ -621,8 +925,12 @@ describe('BridgeController SSE', function () { ...quoteRequest, srcTokenAmount: '10', insufficientBal: false, + resetApproval: false, }, - quotes: [mockBridgeQuotesNativeErc20Eth[0]], + quotes: [mockBridgeQuotesNativeErc20Eth[0]].map((quote) => ({ + ...quote, + resetApproval: undefined, + })), quotesRefreshCount: 1, quoteFetchError: null, quotesLoadingStatus: RequestStatus.LOADING, @@ -716,6 +1024,7 @@ describe('BridgeController SSE', function () { updatedQuoteRequest: { ...quoteRequest, insufficientBal: false, + resetApproval: false, }, context: metricsContext, }); @@ -724,6 +1033,7 @@ describe('BridgeController SSE', function () { ...DEFAULT_BRIDGE_CONTROLLER_STATE, quoteRequest, assetExchangeRates, + quotesLoadingStatus: RequestStatus.LOADING, }; expect(bridgeController.state).toStrictEqual(expectedState); @@ -734,6 +1044,7 @@ describe('BridgeController SSE', function () { { ...quoteRequest, insufficientBal: false, + resetApproval: false, }, expect.any(AbortSignal), BridgeClientId.EXTENSION, @@ -756,7 +1067,11 @@ describe('BridgeController SSE', function () { await flushPromises(); expect(bridgeController.state).toStrictEqual({ ...expectedState, - quoteRequest: { ...quoteRequest, insufficientBal: false }, + quoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, quotesRefreshCount: 1, quotesLoadingStatus: 2, quoteFetchError: 'Bridge-api error: timeout from server', diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index e5f20e0d06c..545c045de33 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -1,5 +1,4 @@ /* eslint-disable jest/no-restricted-matchers */ -import { Contract } from '@ethersproject/contracts'; import { deriveStateFromMetadata } from '@metamask/base-controller'; import { BtcScope, @@ -18,14 +17,11 @@ import { } from './constants/bridge'; import { SWAPS_API_V2_BASE_URL } from './constants/swaps'; import * as selectors from './selectors'; -import { - ChainId, - RequestStatus, - SortOrder, - StatusTypes, - type BridgeControllerMessenger, - type QuoteResponse, - type GenericQuoteRequest, +import { ChainId, RequestStatus, SortOrder, StatusTypes } from './types'; +import type { + BridgeControllerMessenger, + QuoteResponse, + GenericQuoteRequest, } from './types'; import * as balanceUtils from './utils/balance'; import { getNativeAssetForChainId, isSolanaChainId } from './utils/bridge'; @@ -307,7 +303,7 @@ describe('BridgeController', function () { expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }); - it('updateBridgeQuoteRequestParams should not call fetchBridgeQuotes if SSE is not enabled', async function () { + it('updateBridgeQuoteRequestParams should not call fetchBridgeQuotes if SSE is enabled', async function () { jest.useFakeTimers(); const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); @@ -365,6 +361,7 @@ describe('BridgeController', function () { updatedQuoteRequest: { ...quoteRequest, insufficientBal: false, + resetApproval: false, }, context: metricsContext, }); @@ -375,8 +372,7 @@ describe('BridgeController', function () { quoteRequest: { ...quoteRequest, walletAddress: '0x123' }, quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, - quotesLoadingStatus: - DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + quotesLoadingStatus: RequestStatus.LOADING, }), ); @@ -485,6 +481,7 @@ describe('BridgeController', function () { updatedQuoteRequest: { ...quoteRequest, insufficientBal: false, + resetApproval: false, }, context: metricsContext, }); @@ -495,8 +492,7 @@ describe('BridgeController', function () { quoteRequest: { ...quoteRequest, walletAddress: '0x123' }, quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, - quotesLoadingStatus: - DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + quotesLoadingStatus: RequestStatus.LOADING, }), ); @@ -508,6 +504,7 @@ describe('BridgeController', function () { { ...quoteRequest, insufficientBal: false, + resetApproval: false, }, expect.any(AbortSignal), BridgeClientId.EXTENSION, @@ -522,18 +519,26 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, insufficientBal: false }, + quoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, quotes: [], quotesLoadingStatus: 0, }), ); // After first fetch - jest.advanceTimersByTime(10000); + jest.advanceTimersToNextTimer(); await flushPromises(); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, insufficientBal: false }, + quoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, quotes: mockBridgeQuotesNativeErc20Eth, quotesLoadingStatus: 1, }), @@ -543,11 +548,17 @@ describe('BridgeController', function () { expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); // After 2nd fetch - jest.advanceTimersByTime(50000); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); await flushPromises(); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, insufficientBal: false }, + quoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, quotes: [ ...mockBridgeQuotesNativeErc20Eth, ...mockBridgeQuotesNativeErc20Eth, @@ -563,12 +574,18 @@ describe('BridgeController', function () { expect(secondFetchTime).toBeGreaterThan(firstFetchTime!); // After 3nd fetch throws an error - jest.advanceTimersByTime(50000); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); await flushPromises(); expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, insufficientBal: false }, + quoteRequest: { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, quotes: [], quotesLoadingStatus: 2, quoteFetchError: 'Network error', @@ -582,7 +599,7 @@ describe('BridgeController', function () { const thirdFetchTime = bridgeController.state.quotesLastFetched; // Incoming request update aborts current polling - jest.advanceTimersByTime(10000); + jest.advanceTimersToNextTimer(); await flushPromises(); await bridgeController.updateBridgeQuoteRequestParams( { ...quoteRequest, srcTokenAmount: '10', insufficientBal: false }, @@ -591,20 +608,23 @@ describe('BridgeController', function () { token_symbol_source: 'ETH', token_symbol_destination: 'USDC', security_warnings: [], + usd_amount_source: 100, }, ); await flushPromises(); expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); expect(bridgeController.state).toMatchSnapshot(); - // expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); expect(consoleLogSpy).toHaveBeenCalledWith( 'Failed to fetch bridge quotes', new Error('Network error'), ); // Next fetch succeeds - jest.advanceTimersByTime(15000); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); await flushPromises(); expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(4); const { quotesLastFetched, quotes, ...stateWithoutTimestamp } = @@ -767,7 +787,9 @@ describe('BridgeController', function () { ); // Advance timers and check loading state - jest.advanceTimersByTime(200); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); await flushPromises(); expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(bridgeController.state).toStrictEqual( @@ -779,9 +801,9 @@ describe('BridgeController', function () { ); // Advance timers and check final state - jest.advanceTimersByTime(2600); + jest.advanceTimersToNextTimer(); await flushPromises(); - jest.advanceTimersByTime(100); + jest.advanceTimersToNextTimer(); await flushPromises(); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ @@ -791,11 +813,15 @@ describe('BridgeController', function () { nonEvmFeesInNative: '0.000000014', })), quotesLoadingStatus: RequestStatus.FETCHED, - quoteRequest: quoteParams, + quoteRequest: { + ...quoteParams, + resetApproval: false, + insufficientBal: undefined, + }, quoteFetchError: null, assetExchangeRates: {}, quotesRefreshCount: 1, - quotesInitialLoadTime: 2900, + quotesInitialLoadTime: 2100, quotesLastFetched: expect.any(Number), }), ); @@ -842,7 +868,13 @@ describe('BridgeController', function () { quoteParams, metricsContext, ); - jest.advanceTimersByTime(3510); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); await flushPromises(); expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); expect(bridgeController.state).toStrictEqual( @@ -853,7 +885,11 @@ describe('BridgeController', function () { nonEvmFeesInNative: '0.000000014', })), quotesLoadingStatus: RequestStatus.FETCHED, - quoteRequest: quoteParams, + quoteRequest: { + ...quoteParams, + resetApproval: false, + insufficientBal: undefined, + }, quoteFetchError: null, assetExchangeRates: {}, quotesRefreshCount: expect.any(Number), @@ -878,7 +914,13 @@ describe('BridgeController', function () { ); // Check states during failure scenario - jest.advanceTimersByTime(2210); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); await flushPromises(); expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(4); expect(bridgeController.state).toStrictEqual( @@ -889,11 +931,16 @@ describe('BridgeController', function () { nonEvmFeesInNative: '0.000000014', })), quotesLoadingStatus: RequestStatus.FETCHED, - quoteRequest: { ...quoteParams, srcTokenAmount: '11111' }, + quoteRequest: { + ...quoteParams, + srcTokenAmount: '11111', + insufficientBal: undefined, + resetApproval: false, + }, quoteFetchError: null, assetExchangeRates: {}, - quotesRefreshCount: expect.any(Number), - quotesInitialLoadTime: expect.any(Number), + quotesRefreshCount: 1, + quotesInitialLoadTime: 2100, quotesLastFetched: expect.any(Number), }), ); @@ -987,6 +1034,7 @@ describe('BridgeController', function () { updatedQuoteRequest: { ...quoteRequest, insufficientBal: true, + resetApproval: false, }, context: metricsContext, }); @@ -998,8 +1046,7 @@ describe('BridgeController', function () { quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, quotesInitialLoadTime: null, - quotesLoadingStatus: - DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + quotesLoadingStatus: RequestStatus.LOADING, }), ); @@ -1011,6 +1058,7 @@ describe('BridgeController', function () { { ...quoteRequest, insufficientBal: true, + resetApproval: false, }, expect.any(AbortSignal), BridgeClientId.EXTENSION, @@ -1026,7 +1074,11 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, insufficientBal: true }, + quoteRequest: { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, quotes: [], quotesLoadingStatus: 0, quotesLastFetched: t1, @@ -1038,7 +1090,11 @@ describe('BridgeController', function () { await flushPromises(); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, insufficientBal: true }, + quoteRequest: { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, quotes: mockBridgeQuotesNativeErc20Eth, quotesLoadingStatus: 1, quotesRefreshCount: 1, @@ -1071,7 +1127,11 @@ describe('BridgeController', function () { expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, insufficientBal: true }, + quoteRequest: { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, quotes: mockBridgeQuotesNativeErc20Eth, quotesLoadingStatus: 1, quotesRefreshCount: 1, @@ -1185,6 +1245,7 @@ describe('BridgeController', function () { updatedQuoteRequest: { ...quoteRequest, insufficientBal: true, + resetApproval: false, }, context: metricsContext, }); @@ -1198,7 +1259,11 @@ describe('BridgeController', function () { await flushPromises(); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, insufficientBal: true }, + quoteRequest: { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, quotes: mockBridgeQuotesNativeErc20Eth, quotesLoadingStatus: 1, quotesRefreshCount: 1, @@ -1291,100 +1356,6 @@ describe('BridgeController', function () { ); }); - describe('getBridgeERC20Allowance', () => { - it('should return the atomic allowance of the ERC20 token contract', async () => { - (Contract as unknown as jest.Mock).mockImplementation(() => ({ - allowance: jest.fn(() => '100000000000000000000'), - })); - - messengerMock.call - .mockReturnValueOnce('networkClientId-for-chain-0xa') - .mockReturnValueOnce({ - // getNetworkClientById - address: '0x123', - provider: jest.fn(), - } as never); - - const allowance = await bridgeController.getBridgeERC20Allowance( - '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', - '0xa', - ); - expect(allowance).toBe('100000000000000000000'); - expect(messengerMock.call).toHaveBeenCalledTimes(2); - expect(messengerMock.call).toHaveBeenNthCalledWith( - 1, - 'NetworkController:findNetworkClientIdByChainId', - '0xa', - ); - expect(messengerMock.call).toHaveBeenNthCalledWith( - 2, - 'NetworkController:getNetworkClientById', - 'networkClientId-for-chain-0xa', - ); - }); - - it('should throw an error when no network client is found for chainId', async () => { - // Setup - const mockMessenger = { - call: jest.fn().mockImplementation((methodName) => { - if (methodName === 'NetworkController:findNetworkClientIdByChainId') { - return undefined; // No network client found - } - return undefined; - }), - registerActionHandler: jest.fn(), - publish: jest.fn(), - registerInitialEventPayload: jest.fn(), - } as unknown as jest.Mocked; - - const controller = new BridgeController({ - messenger: mockMessenger, - clientId: BridgeClientId.EXTENSION, - clientVersion: '1.0.0', - getLayer1GasFee: jest.fn(), - fetchFn: mockFetchFn, - trackMetaMetricsFn, - }); - - // Test - await expect( - controller.getBridgeERC20Allowance('0xContractAddress', '0x1'), - ).rejects.toThrow('No network client found for chainId: 0x1'); - }); - - it('should throw an error when no provider is found', async () => { - // Setup - const mockMessenger = { - call: jest.fn().mockImplementation((methodName) => { - if (methodName === 'NetworkController:findNetworkClientIdByChainId') { - return 'networkClientId-for-chain-0x1'; - } - if (methodName === 'NetworkController:getNetworkClientById') { - return { provider: null }; - } - return undefined; - }), - registerActionHandler: jest.fn(), - publish: jest.fn(), - registerInitialEventPayload: jest.fn(), - } as unknown as jest.Mocked; - - const controller = new BridgeController({ - messenger: mockMessenger, - clientId: BridgeClientId.EXTENSION, - clientVersion: '1.0.0', - getLayer1GasFee: jest.fn(), - fetchFn: mockFetchFn, - trackMetaMetricsFn, - }); - - // Test - await expect( - controller.getBridgeERC20Allowance('0xContractAddress', '0x1'), - ).rejects.toThrow('No provider found'); - }); - }); - it.each([ [ 'should append l1GasFees if srcChain is 10 and srcToken is erc20', @@ -1499,6 +1470,7 @@ describe('BridgeController', function () { updatedQuoteRequest: { ...quoteRequest, insufficientBal: true, + resetApproval: false, }, context: metricsContext, }); @@ -1508,8 +1480,7 @@ describe('BridgeController', function () { quoteRequest, quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, - quotesLoadingStatus: - DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + quotesLoadingStatus: RequestStatus.LOADING, }), ); @@ -1521,6 +1492,7 @@ describe('BridgeController', function () { { ...quoteRequest, insufficientBal: true, + resetApproval: false, }, expect.any(AbortSignal), BridgeClientId.EXTENSION, @@ -1535,7 +1507,11 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, insufficientBal: true }, + quoteRequest: { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, quotes: [], quotesLoadingStatus: 0, }), @@ -1548,7 +1524,11 @@ describe('BridgeController', function () { expect(quotes).toHaveLength(expectedQuotesLength); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, insufficientBal: true }, + quoteRequest: { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, quotesLoadingStatus: 1, quotesRefreshCount: 1, }), @@ -1633,9 +1613,10 @@ describe('BridgeController', function () { ); // Advance timers to trigger fetch - jest.advanceTimersByTime(1000); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); await flushPromises(); - // Verify state wasn't updated due to abort expect(bridgeController.state.quoteFetchError).toBe('Other error'); expect(bridgeController.state.quotesLoadingStatus).toBe( @@ -1643,10 +1624,8 @@ describe('BridgeController', function () { ); expect(bridgeController.state.quotes).toStrictEqual([]); - // Verify state wasn't updated due to reset + // Verify state is reset bridgeController.resetState(); - jest.advanceTimersByTime(1000); - await flushPromises(); expect(bridgeController.state.quoteFetchError).toBeNull(); expect(bridgeController.state.quotesLoadingStatus).toBeNull(); expect(bridgeController.state.quotes).toStrictEqual([]); @@ -1656,6 +1635,9 @@ describe('BridgeController', function () { quoteParams, metricsContext, ); + + jest.advanceTimersToNextTimer(); + await flushPromises(); jest.advanceTimersByTime(10000); await flushPromises(); const { quotes, quotesLastFetched, ...stateWithoutQuotes } = @@ -2035,7 +2017,7 @@ describe('BridgeController', function () { return setTimeout(() => { resolve([ { - type: 'base', + type: 'priority', asset: { unit: 'BTC', type: 'bip122:000000000019d6689c085ae165831e93/slip44:0', @@ -2112,6 +2094,10 @@ describe('BridgeController', function () { }, })) as unknown as QuoteResponse[]; + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(jest.fn()); + messengerMock.call.mockImplementation( ( ...args: Parameters @@ -2192,6 +2178,15 @@ describe('BridgeController', function () { expect(quotes).toHaveLength(2); // mockBridgeQuotesSolErc20 has 2 quotes expect(quotes[0].nonEvmFeesInNative).toBeUndefined(); expect(quotes[1].nonEvmFeesInNative).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalledTimes(2); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to compute non-EVM fees for quote 5cb5a527-d4e4-4b5e-b753-136afc3986d3:', + new Error('Failed to compute fees'), + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to compute non-EVM fees for quote 12c94d29-4b5c-4aee-92de-76eee4172d3d:', + new Error('Failed to compute fees'), + ); }); describe('trackUnifiedSwapBridgeEvent client-side calls', () => { @@ -2209,6 +2204,7 @@ describe('BridgeController', function () { security_warnings: [], token_symbol_source: 'ETH', token_symbol_destination: 'USDC', + usd_amount_source: 100, }, ); jest.clearAllMocks(); @@ -2656,6 +2652,7 @@ describe('BridgeController', function () { stx_enabled: false, security_warnings: [], token_symbol_source: 'ETH', + usd_amount_source: 100, token_symbol_destination: 'USDC', }, ); @@ -2677,7 +2674,7 @@ describe('BridgeController', function () { expect(trackMetaMetricsFn).toHaveBeenCalledTimes(0); expect(errorSpy).toHaveBeenCalledTimes(1); expect(errorSpy).toHaveBeenCalledWith( - 'Error tracking cross-chain swaps MetaMetrics event', + 'Error tracking cross-chain swaps MetaMetrics event Unified SwapBridge Quotes Received', new TypeError("Cannot read properties of undefined (reading 'type')"), ); }); @@ -2788,6 +2785,7 @@ describe('BridgeController', function () { "fee": 0, "gasIncluded": false, "gasIncluded7702": false, + "resetApproval": false, "slippage": 0.5, "srcChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "srcTokenAddress": "NATIVE", @@ -2883,6 +2881,7 @@ describe('BridgeController', function () { "fee": 0, "gasIncluded": false, "gasIncluded7702": false, + "resetApproval": false, "slippage": 0.5, "srcChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "srcTokenAddress": "NATIVE", @@ -2935,6 +2934,7 @@ describe('BridgeController', function () { "destTokenAddress": "0x1234", "gasIncluded": false, "gasIncluded7702": false, + "resetApproval": false, "slippage": 0.5, "srcChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "srcTokenAddress": "NATIVE", diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 7bd5bb28552..56c620b2e95 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -1,4 +1,4 @@ -import type { BigNumber } from '@ethersproject/bignumber'; +import { BigNumber } from '@ethersproject/bignumber'; import { Contract } from '@ethersproject/contracts'; import { Web3Provider } from '@ethersproject/providers'; import type { StateMetadata } from '@metamask/base-controller'; @@ -14,27 +14,30 @@ import { BRIDGE_CONTROLLER_NAME, BRIDGE_PROD_API_BASE_URL, DEFAULT_BRIDGE_CONTROLLER_STATE, - METABRIDGE_CHAIN_TO_ADDRESS_MAP, + METABRIDGE_ETHEREUM_ADDRESS, REFRESH_INTERVAL_MS, } from './constants/bridge'; +import { CHAIN_IDS } from './constants/chains'; +import { SWAPS_CONTRACT_ADDRESSES } from './constants/swaps'; import { TraceName } from './constants/traces'; import { selectIsAssetExchangeRateInState } from './selectors'; -import type { QuoteRequest } from './types'; -import { - type L1GasFees, - type GenericQuoteRequest, - type NonEvmFees, - type QuoteResponse, - type BridgeControllerState, - type BridgeControllerMessenger, - type FetchFunction, - RequestStatus, +import { RequestStatus } from './types'; +import type { + L1GasFees, + GenericQuoteRequest, + NonEvmFees, + QuoteRequest, + QuoteResponse, + BridgeControllerState, + BridgeControllerMessenger, + FetchFunction, } from './types'; import { getAssetIdsForToken, toExchangeRates } from './utils/assets'; import { hasSufficientBalance } from './utils/balance'; import { getDefaultBridgeControllerState, isCrossChain, + isEthUsdt, isNonEvmChainId, isSolanaChainId, } from './utils/bridge'; @@ -71,7 +74,7 @@ import type { RequestMetadata, RequiredEventContextFromClient, } from './utils/metrics/types'; -import { type CrossChainSwapsEventProperties } from './utils/metrics/types'; +import type { CrossChainSwapsEventProperties } from './utils/metrics/types'; import { isValidQuoteRequest, sortQuotes } from './utils/quote'; import { appendFeesToQuotes } from './utils/quote-fees'; import { getMinimumBalanceForRentExemptionInLamports } from './utils/snaps'; @@ -247,10 +250,6 @@ export class BridgeController extends StaticIntervalPollingController { + state.quotesLoadingStatus = RequestStatus.LOADING; + }); + resetApproval = await this.#shouldResetApproval(updatedQuoteRequest); + // Otherwise query the src token balance from the RPC provider + insufficientBal = + paramsToUpdate.insufficientBal ?? + (await this.#hasInsufficientBalance(updatedQuoteRequest)); } // Set refresh rate based on the source chain before starting polling @@ -324,6 +324,7 @@ export class BridgeController extends StaticIntervalPollingController { - const srcChainIdInHex = formatChainIdToHex(quoteRequest.srcChainId); - const provider = this.#getNetworkClientByChainId(srcChainIdInHex)?.provider; - const normalizedSrcTokenAddress = formatAddressToCaipReference( - quoteRequest.srcTokenAddress, - ); + try { + const srcChainIdInHex = formatChainIdToHex(quoteRequest.srcChainId); + const provider = + this.#getNetworkClientByChainId(srcChainIdInHex)?.provider; + const normalizedSrcTokenAddress = formatAddressToCaipReference( + quoteRequest.srcTokenAddress, + ); - return ( - provider && - normalizedSrcTokenAddress && - quoteRequest.srcTokenAmount && - srcChainIdInHex && - (await hasSufficientBalance( - provider, - quoteRequest.walletAddress, - normalizedSrcTokenAddress, - quoteRequest.srcTokenAmount, - srcChainIdInHex, - )) - ); + return !( + provider && + normalizedSrcTokenAddress && + quoteRequest.srcTokenAmount && + srcChainIdInHex && + (await hasSufficientBalance( + provider, + quoteRequest.walletAddress, + normalizedSrcTokenAddress, + quoteRequest.srcTokenAmount, + srcChainIdInHex, + )) + ); + } catch (error) { + console.warn('Failed to set insufficientBal', error); + // Fall back to true so the backend returns quotes + return true; + } }; - stopPollingForQuotes = (reason?: AbortReason) => { + readonly #shouldResetApproval = async (quoteRequest: GenericQuoteRequest) => { + if (isNonEvmChainId(quoteRequest.srcChainId)) { + return false; + } + try { + const normalizedSrcTokenAddress = formatAddressToCaipReference( + quoteRequest.srcTokenAddress, + ); + if (isEthUsdt(quoteRequest.srcChainId, normalizedSrcTokenAddress)) { + const allowance = BigNumber.from( + await this.#getUSDTMainnetAllowance( + quoteRequest.walletAddress, + normalizedSrcTokenAddress, + quoteRequest.destChainId, + ), + ); + return allowance.lt(quoteRequest.srcTokenAmount) && allowance.gt(0); + } + return false; + } catch (error) { + console.warn('Failed to set resetApproval', error); + // Fall back to true so the backend returns quotes + return true; + } + }; + + stopPollingForQuotes = ( + reason?: AbortReason, + context?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], + ) => { this.stopAllPolling(); + // If polling is stopped before quotes finish loading, track QuotesReceived + if (this.state.quotesLoadingStatus === RequestStatus.LOADING && context) { + this.trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.QuotesReceived, + context, + ); + } + // Clears quotes list in state this.#abortController?.abort(reason); }; @@ -580,7 +626,7 @@ export class BridgeController extends StaticIntervalPollingController { /** * Tracks the number of valid quotes received from the current stream, which is used * to determine when to clear the quotes list and set the initial load time */ let validQuotesCounter = 0; + /** + * Tracks all pending promises from appendFeesToQuotes calls to ensure they complete + * before setting quotesLoadingStatus to FETCHED + */ + const pendingFeeAppendPromises = new Set>(); await fetchBridgeQuoteStream( this.#fetchFn, @@ -688,33 +740,51 @@ export class BridgeController extends StaticIntervalPollingController { - const quotesWithFees = await appendFeesToQuotes( - [quote], - this.messenger, - this.#getLayer1GasFee, - selectedAccount, - ); - if (quotesWithFees.length > 0) { - validQuotesCounter += 1; - } - this.update((state) => { - // Clear previous quotes and quotes load time when first quote in the current - // polling loop is received - // This enables clients to continue showing the previous quotes while new - // quotes are loading - // Note: If there are no valid quotes until the 2nd fetch, quotesInitialLoadTime will be > refreshRate - if (validQuotesCounter === 1) { - state.quotes = DEFAULT_BRIDGE_CONTROLLER_STATE.quotes; - if (!state.quotesInitialLoadTime && this.#quotesFirstFetched) { - // Set the initial load time after the first quote is received - state.quotesInitialLoadTime = - Date.now() - this.#quotesFirstFetched; - } + const feeAppendPromise = (async () => { + const quotesWithFees = await appendFeesToQuotes( + [quote], + this.messenger, + this.#getLayer1GasFee, + selectedAccount, + ); + if (quotesWithFees.length > 0) { + validQuotesCounter += 1; } - state.quotes = [...state.quotes, ...quotesWithFees]; - }); + this.update((state) => { + // Clear previous quotes and quotes load time when first quote in the current + // polling loop is received + // This enables clients to continue showing the previous quotes while new + // quotes are loading + // Note: If there are no valid quotes until the 2nd fetch, quotesInitialLoadTime will be > refreshRate + if (validQuotesCounter === 1) { + state.quotes = DEFAULT_BRIDGE_CONTROLLER_STATE.quotes; + if (!state.quotesInitialLoadTime && this.#quotesFirstFetched) { + // Set the initial load time after the first quote is received + state.quotesInitialLoadTime = + Date.now() - this.#quotesFirstFetched; + } + } + state.quotes = [...state.quotes, ...quotesWithFees]; + }); + })(); + pendingFeeAppendPromises.add(feeAppendPromise); + feeAppendPromise + .catch((error) => { + // Catch errors to prevent them from breaking stream processing + // If appendFeesToQuotes throws, the state update never happens, so no invalid entry is added + console.error('Error appending fees to quote', error); + }) + .finally(() => { + pendingFeeAppendPromises.delete(feeAppendPromise); + }); + // Await the promise to ensure errors are caught and handled before continuing + // The promise is also tracked in pendingFeeAppendPromises for onClose to wait for + await feeAppendPromise; }, - onClose: () => { + onClose: async () => { + // Wait for all pending appendFeesToQuotes operations to complete + // before setting quotesLoadingStatus to FETCHED + await Promise.allSettled(Array.from(pendingFeeAppendPromises)); this.update((state) => { // If there are no valid quotes in the current stream, clear the quotes list // to remove quotes from the previous stream @@ -755,9 +825,6 @@ export class BridgeController extends StaticIntervalPollingController => { - const networkClient = this.#getNetworkClientByChainId(chainId); + const networkClient = this.#getNetworkClientByChainId(CHAIN_IDS.MAINNET); const provider = networkClient?.provider; if (!provider) { throw new Error('No provider found'); @@ -969,9 +1038,12 @@ export class BridgeController extends StaticIntervalPollingController = { + [CHAIN_IDS.MAINNET]: ETH_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.LOCALHOST]: ETH_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.BSC]: BSC_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.POLYGON]: POLYGON_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.AVALANCHE]: AVALANCHE_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.ARBITRUM]: ARBITRUM_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.OPTIMISM]: OPTIMISM_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.ZKSYNC_ERA]: ZKSYNC_ERA_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.LINEA_MAINNET]: LINEA_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.BASE]: BASE_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.SEI]: SEI_SWAPS_CONTRACT_ADDRESS, +}; + +const WETH_CONTRACT_ADDRESS = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; +const WBNB_CONTRACT_ADDRESS = '0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c'; +const WMATIC_CONTRACT_ADDRESS = '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270'; +const WAVAX_CONTRACT_ADDRESS = '0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7'; +const WETH_ARBITRUM_CONTRACT_ADDRESS = + '0x82af49447d8a07e3bd95bd0d56f35241523fbab1'; +const WETH_OPTIMISM_CONTRACT_ADDRESS = + '0x4200000000000000000000000000000000000006'; +const WETH_ZKSYNC_ERA_CONTRACT_ADDRESS = + '0x5AEa5775959fBC2557Cc8789bC1bf90A239D9a91'; +const WETH_LINEA_CONTRACT_ADDRESS = + '0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f'; +const WETH_BASE_CONTRACT_ADDRESS = '0x4200000000000000000000000000000000000006'; +const WSEI_SEI_CONTRACT_ADDRESS = '0xe30fedd158a2e3b13e9badaeabafc5516e95e8c7'; + +export const SWAPS_WRAPPED_TOKENS_ADDRESSES: Record = { + [CHAIN_IDS.MAINNET]: WETH_CONTRACT_ADDRESS, + [CHAIN_IDS.LOCALHOST]: WETH_CONTRACT_ADDRESS, + [CHAIN_IDS.BSC]: WBNB_CONTRACT_ADDRESS, + [CHAIN_IDS.POLYGON]: WMATIC_CONTRACT_ADDRESS, + [CHAIN_IDS.AVALANCHE]: WAVAX_CONTRACT_ADDRESS, + [CHAIN_IDS.ARBITRUM]: WETH_ARBITRUM_CONTRACT_ADDRESS, + [CHAIN_IDS.OPTIMISM]: WETH_OPTIMISM_CONTRACT_ADDRESS, + [CHAIN_IDS.ZKSYNC_ERA]: WETH_ZKSYNC_ERA_CONTRACT_ADDRESS, + [CHAIN_IDS.LINEA_MAINNET]: WETH_LINEA_CONTRACT_ADDRESS, + [CHAIN_IDS.BASE]: WETH_BASE_CONTRACT_ADDRESS, + [CHAIN_IDS.SEI]: WSEI_SEI_CONTRACT_ADDRESS, +}; + +export const ALLOWED_CONTRACT_ADDRESSES: Record = { + [CHAIN_IDS.MAINNET]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.MAINNET], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.MAINNET], + ], + [CHAIN_IDS.LOCALHOST]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.LOCALHOST], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.LOCALHOST], + ], + [CHAIN_IDS.BSC]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.BSC], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.BSC], + ], + [CHAIN_IDS.POLYGON]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.POLYGON], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.POLYGON], + ], + [CHAIN_IDS.AVALANCHE]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.AVALANCHE], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.AVALANCHE], + ], + [CHAIN_IDS.ARBITRUM]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.ARBITRUM], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.ARBITRUM], + ], + [CHAIN_IDS.OPTIMISM]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.OPTIMISM], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.OPTIMISM], + ], + [CHAIN_IDS.ZKSYNC_ERA]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.ZKSYNC_ERA], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.ZKSYNC_ERA], + ], + [CHAIN_IDS.LINEA_MAINNET]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.LINEA_MAINNET], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.LINEA_MAINNET], + ], + [CHAIN_IDS.BASE]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.BASE], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.BASE], + ], + [CHAIN_IDS.SEI]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.SEI], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.SEI], + ], +}; diff --git a/packages/bridge-controller/src/constants/tokens.ts b/packages/bridge-controller/src/constants/tokens.ts index fb0d067c8dc..5db2e6b1f58 100644 --- a/packages/bridge-controller/src/constants/tokens.ts +++ b/packages/bridge-controller/src/constants/tokens.ts @@ -26,7 +26,8 @@ export type SwapsTokenObject = { iconUrl: string; }; -const DEFAULT_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000000'; +export const DEFAULT_TOKEN_ADDRESS = + '0x0000000000000000000000000000000000000000'; const CURRENCY_SYMBOLS = { ARBITRUM: 'ETH', @@ -174,11 +175,9 @@ const MONAD_SWAPS_TOKEN_OBJECT = { iconUrl: '', } as const; -const SWAPS_TESTNET_CHAIN_ID = '0x539'; - export const SWAPS_CHAINID_DEFAULT_TOKEN_MAP = { [CHAIN_IDS.MAINNET]: ETH_SWAPS_TOKEN_OBJECT, - [SWAPS_TESTNET_CHAIN_ID]: TEST_ETH_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.LOCALHOST]: TEST_ETH_SWAPS_TOKEN_OBJECT, [CHAIN_IDS.BSC]: BNB_SWAPS_TOKEN_OBJECT, [CHAIN_IDS.POLYGON]: MATIC_SWAPS_TOKEN_OBJECT, [CHAIN_IDS.GOERLI]: GOERLI_SWAPS_TOKEN_OBJECT, @@ -200,7 +199,7 @@ export const SWAPS_CHAINID_DEFAULT_TOKEN_MAP = { export type SupportedSwapsNativeCurrencySymbols = (typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP)[ | AllowedBridgeChainIds - | typeof SWAPS_TESTNET_CHAIN_ID]['symbol']; + | typeof CHAIN_IDS.LOCALHOST]['symbol']; /** * A map of native currency symbols to their SLIP-44 representation diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 21a678b8940..5cd6b421bf8 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -52,6 +52,8 @@ export type { FeatureFlagsPlatformConfig, } from './types'; +export { AbortReason } from './utils/metrics/constants'; + export { StatusTypes } from './types'; export { @@ -101,12 +103,16 @@ export { SWAPS_CHAINID_DEFAULT_TOKEN_MAP, } from './constants/tokens'; -export { SWAPS_API_V2_BASE_URL } from './constants/swaps'; +export { + SWAPS_API_V2_BASE_URL, + SWAPS_CONTRACT_ADDRESSES, + SWAPS_WRAPPED_TOKENS_ADDRESSES, + ALLOWED_CONTRACT_ADDRESSES, +} from './constants/swaps'; export { MetricsActionType, MetricsSwapType } from './utils/metrics/constants'; export { - getEthUsdtResetData, isEthUsdt, isNativeAddress, isSolanaChainId, @@ -138,6 +144,7 @@ export { extractTradeData, isBitcoinTrade, isTronTrade, + isEvmTxData, type Trade, } from './utils/trade-utils'; @@ -156,3 +163,10 @@ export { DEFAULT_FEATURE_FLAG_CONFIG } from './constants/bridge'; export { getBridgeFeatureFlags } from './utils/feature-flags'; export { BRIDGE_DEFAULT_SLIPPAGE } from './utils/slippage'; + +export { + isValidSwapsContractAddress, + getSwapsContractAddress, + fetchTokens, + type SwapsToken, +} from './utils/swaps'; diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index bc82a628366..042ce6239b4 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -208,11 +208,11 @@ describe('Bridge Selectors', () => { estimatedBaseFee: '50', medium: { suggestedMaxPriorityFeePerGas: '75', - suggestedMaxFeePerGas: '1', + suggestedMaxFeePerGas: '75.5', }, high: { suggestedMaxPriorityFeePerGas: '100', - suggestedMaxFeePerGas: '2', + suggestedMaxFeePerGas: '100.5', }, }, } as unknown as BridgeAppState; @@ -391,11 +391,11 @@ describe('Bridge Selectors', () => { estimatedBaseFee: '0', medium: { suggestedMaxPriorityFeePerGas: '.1', - suggestedMaxFeePerGas: '.1', + suggestedMaxFeePerGas: '.15', }, high: { suggestedMaxPriorityFeePerGas: '.1', - suggestedMaxFeePerGas: '.1', + suggestedMaxFeePerGas: '.15', }, }, } as unknown as BridgeAppState; @@ -551,28 +551,28 @@ describe('Bridge Selectors', () => { expect(quoteMetadata).toMatchInlineSnapshot(` Object { "adjustedReturn": Object { - "usd": "10.513424894341876155230359150867612640256", - "valueInCurrency": "8.995536137740000000254299423511757231474", + "usd": "10.518641979781876155230359150867612640256", + "valueInCurrency": "9.000000000000000000254299423511757231474", }, "cost": Object { - "usd": "1.173955083193541475489640849132387359744", - "valueInCurrency": "1.004463862259999726625700576488242768526", + "usd": "1.168737997753541475489640849132387359744", + "valueInCurrency": "0.999999999999999726625700576488242768526", }, "gasFee": Object { "effective": Object { - "amount": "0.000008087", - "usd": "0.00521708544", - "valueInCurrency": "0.00446386226", + "amount": "0", + "usd": "0", + "valueInCurrency": "0", }, "max": Object { - "amount": "0.000016174", - "usd": "0.01043417088", - "valueInCurrency": "0.00892772452", + "amount": "0.0000121305", + "usd": "0.00782562816", + "valueInCurrency": "0.00669579339", }, "total": Object { - "amount": "0.000008087", - "usd": "0.00521708544", - "valueInCurrency": "0.00446386226", + "amount": "0", + "usd": "0", + "valueInCurrency": "0", }, }, "includedTxFees": null, @@ -593,14 +593,14 @@ describe('Bridge Selectors', () => { "valueInCurrency": "9.000000000000000000254299423511757231474", }, "totalMaxNetworkFee": Object { - "amount": "0.000016174", - "usd": "0.01043417088", - "valueInCurrency": "0.00892772452", + "amount": "0.0000121305", + "usd": "0.00782562816", + "valueInCurrency": "0.00669579339", }, "totalNetworkFee": Object { - "amount": "0.000008087", - "usd": "0.00521708544", - "valueInCurrency": "0.00446386226", + "amount": "0", + "usd": "0", + "valueInCurrency": "0", }, } `); @@ -634,28 +634,28 @@ describe('Bridge Selectors', () => { expect(quoteMetadata).toMatchInlineSnapshot(` Object { "adjustedReturn": Object { - "usd": "10.51342489434187625472", - "valueInCurrency": "8.99553613774000008538", + "usd": "10.51864197978187625472", + "valueInCurrency": "9.00000000000000008538", }, "cost": Object { - "usd": "1.173955083193541695202677292586583974912", - "valueInCurrency": "1.004463862259999914617394921816007289298", + "usd": "1.168737997753541695202677292586583974912", + "valueInCurrency": "0.999999999999999914617394921816007289298", }, "gasFee": Object { "effective": Object { - "amount": "0.000008087", - "usd": "0.00521708544", - "valueInCurrency": "0.00446386226", + "amount": "0", + "usd": "0", + "valueInCurrency": "0", }, "max": Object { - "amount": "0.000016174", - "usd": "0.01043417088", - "valueInCurrency": "0.00892772452", + "amount": "0.0000121305", + "usd": "0.00782562816", + "valueInCurrency": "0.00669579339", }, "total": Object { - "amount": "0.000008087", - "usd": "0.00521708544", - "valueInCurrency": "0.00446386226", + "amount": "0", + "usd": "0", + "valueInCurrency": "0", }, }, "includedTxFees": null, @@ -676,14 +676,14 @@ describe('Bridge Selectors', () => { "valueInCurrency": "9.00000000000000008538", }, "totalMaxNetworkFee": Object { - "amount": "0.000016174", - "usd": "0.01043417088", - "valueInCurrency": "0.00892772452", + "amount": "0.0000121305", + "usd": "0.00782562816", + "valueInCurrency": "0.00669579339", }, "totalNetworkFee": Object { - "amount": "0.000008087", - "usd": "0.00521708544", - "valueInCurrency": "0.00446386226", + "amount": "0", + "usd": "0", + "valueInCurrency": "0", }, } `); @@ -735,19 +735,19 @@ describe('Bridge Selectors', () => { }, "gasFee": Object { "effective": Object { - "amount": "0.000008087", - "usd": "0.00521708544", - "valueInCurrency": "0.00446386226", + "amount": "0", + "usd": "0", + "valueInCurrency": "0", }, "max": Object { - "amount": "0.000016174", - "usd": "0.01043417088", - "valueInCurrency": "0.00892772452", + "amount": "0.0000121305", + "usd": "0.00782562816", + "valueInCurrency": "0.00669579339", }, "total": Object { - "amount": "0.000008087", - "usd": "0.00521708544", - "valueInCurrency": "0.00446386226", + "amount": "0", + "usd": "0", + "valueInCurrency": "0", }, }, "includedTxFees": Object { @@ -772,14 +772,14 @@ describe('Bridge Selectors', () => { "valueInCurrency": "9.00000000000000008538", }, "totalMaxNetworkFee": Object { - "amount": "0.000016174", - "usd": "0.01043417088", - "valueInCurrency": "0.00892772452", + "amount": "0.0000121305", + "usd": "0.00782562816", + "valueInCurrency": "0.00669579339", }, "totalNetworkFee": Object { - "amount": "0.000008087", - "usd": "0.00521708544", - "valueInCurrency": "0.00446386226", + "amount": "0", + "usd": "0", + "valueInCurrency": "0", }, } `); @@ -831,19 +831,19 @@ describe('Bridge Selectors', () => { }, "gasFee": Object { "effective": Object { - "amount": "0.000008087", - "usd": "0.00521708544", - "valueInCurrency": "0.00446386226", + "amount": "0", + "usd": "0", + "valueInCurrency": "0", }, "max": Object { - "amount": "0.000016174", - "usd": "0.01043417088", - "valueInCurrency": "0.00892772452", + "amount": "0.0000121305", + "usd": "0.00782562816", + "valueInCurrency": "0.00669579339", }, "total": Object { - "amount": "0.000008087", - "usd": "0.00521708544", - "valueInCurrency": "0.00446386226", + "amount": "0", + "usd": "0", + "valueInCurrency": "0", }, }, "includedTxFees": Object { @@ -868,14 +868,14 @@ describe('Bridge Selectors', () => { "valueInCurrency": "9.00000000000000008538", }, "totalMaxNetworkFee": Object { - "amount": "0.000016174", - "usd": "0.01043417088", - "valueInCurrency": "0.00892772452", + "amount": "0.0000121305", + "usd": "0.00782562816", + "valueInCurrency": "0.00669579339", }, "totalNetworkFee": Object { - "amount": "0.000008087", - "usd": "0.00521708544", - "valueInCurrency": "0.00446386226", + "amount": "0", + "usd": "0", + "valueInCurrency": "0", }, } `); @@ -929,19 +929,19 @@ describe('Bridge Selectors', () => { }, "gasFee": Object { "effective": Object { - "amount": "0.000008087", - "usd": "0.00521708544", - "valueInCurrency": "0.00446386226", + "amount": "0", + "usd": "0", + "valueInCurrency": "0", }, "max": Object { - "amount": "0.000016174", - "usd": "0.01043417088", - "valueInCurrency": "0.00892772452", + "amount": "0.0000121305", + "usd": "0.00782562816", + "valueInCurrency": "0.00669579339", }, "total": Object { - "amount": "0.000008087", - "usd": "0.00521708544", - "valueInCurrency": "0.00446386226", + "amount": "0", + "usd": "0", + "valueInCurrency": "0", }, }, "includedTxFees": Object { @@ -966,14 +966,14 @@ describe('Bridge Selectors', () => { "valueInCurrency": "8.999999999999999949780980627632791914", }, "totalMaxNetworkFee": Object { - "amount": "0.000016174", - "usd": "0.01043417088", - "valueInCurrency": "0.00892772452", + "amount": "0.0000121305", + "usd": "0.00782562816", + "valueInCurrency": "0.00669579339", }, "totalNetworkFee": Object { - "amount": "0.000008087", - "usd": "0.00521708544", - "valueInCurrency": "0.00446386226", + "amount": "0", + "usd": "0", + "valueInCurrency": "0", }, } `); diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index daf51b06fae..a2acca431ef 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -265,7 +265,15 @@ export type QuoteResponse< > = Infer & { trade: TxDataType; approval?: ApprovalType; + /** + * Appended to the quote response based on the quote request + */ featureId?: FeatureId; + /** + * Appended to the quote response based on the quote request resetApproval flag + * If defined, the quote's total network fee will include the reset approval's gas limit. + */ + resetApproval?: TxData; }; export enum ChainId { @@ -281,6 +289,8 @@ export enum ChainId { SOLANA = 1151111081099710, BTC = 20000000000001, TRON = 728126428, + SEI = 1329, + MONAD = 143, } export type FeatureFlagsPlatformConfig = Infer; @@ -297,7 +307,6 @@ export enum BridgeUserAction { export enum BridgeBackgroundAction { SET_CHAIN_INTERVAL_LENGTH = 'setChainIntervalLength', RESET_STATE = 'resetState', - GET_BRIDGE_ERC20_ALLOWANCE = 'getBridgeERC20Allowance', TRACK_METAMETRICS_EVENT = 'trackUnifiedSwapBridgeEvent', STOP_POLLING_FOR_QUOTES = 'stopPollingForQuotes', FETCH_QUOTES = 'fetchQuotes', @@ -364,7 +373,6 @@ export type BridgeControllerActions = | BridgeControllerGetStateAction | BridgeControllerAction | BridgeControllerAction - | BridgeControllerAction | BridgeControllerAction | BridgeControllerAction | BridgeControllerAction diff --git a/packages/bridge-controller/src/utils/assets.ts b/packages/bridge-controller/src/utils/assets.ts index 09bee3b986d..4c29d38c740 100644 --- a/packages/bridge-controller/src/utils/assets.ts +++ b/packages/bridge-controller/src/utils/assets.ts @@ -26,17 +26,16 @@ export const toExchangeRates = ( [assetId: CaipAssetType]: { [currency: string]: string } | undefined; }, ) => { - const exchangeRates = Object.entries(pricesByAssetId).reduce( - (acc, [assetId, prices]) => { - if (prices) { - acc[assetId as CaipAssetType] = { - exchangeRate: prices[currency], - usdExchangeRate: prices.usd, - }; - } - return acc; - }, - {} as Record, - ); + const exchangeRates = Object.entries(pricesByAssetId).reduce< + Record + >((acc, [assetId, prices]) => { + if (prices) { + acc[assetId as CaipAssetType] = { + exchangeRate: prices[currency], + usdExchangeRate: prices.usd, + }; + } + return acc; + }, {}); return exchangeRates; }; diff --git a/packages/bridge-controller/src/utils/bridge.test.ts b/packages/bridge-controller/src/utils/bridge.test.ts index b042da3ba8c..cf9039168f7 100644 --- a/packages/bridge-controller/src/utils/bridge.test.ts +++ b/packages/bridge-controller/src/utils/bridge.test.ts @@ -1,10 +1,7 @@ -import { Contract } from '@ethersproject/contracts'; import { BtcScope, SolScope } from '@metamask/keyring-api'; -import { abiERC20 } from '@metamask/metamask-eth-abis'; import type { Hex } from '@metamask/utils'; import { - getEthUsdtResetData, getNativeAssetForChainId, isBitcoinChainId, isCrossChain, @@ -61,19 +58,6 @@ describe('Bridge utils', () => { }); }); - describe('getEthUsdtResetData', () => { - it('returns correct encoded function data for USDT approval reset', () => { - const expectedInterface = new Contract(ETH_USDT_ADDRESS, abiERC20) - .interface; - const expectedData = expectedInterface.encodeFunctionData('approve', [ - METABRIDGE_ETHEREUM_ADDRESS, - '0', - ]); - - expect(getEthUsdtResetData()).toBe(expectedData); - }); - }); - describe('isEthUsdt', () => { it('returns true for ETH USDT address on mainnet', () => { expect(isEthUsdt(CHAIN_IDS.MAINNET, ETH_USDT_ADDRESS)).toBe(true); diff --git a/packages/bridge-controller/src/utils/bridge.ts b/packages/bridge-controller/src/utils/bridge.ts index 423c77fa09a..c9b5be725f8 100644 --- a/packages/bridge-controller/src/utils/bridge.ts +++ b/packages/bridge-controller/src/utils/bridge.ts @@ -2,8 +2,8 @@ import { AddressZero } from '@ethersproject/constants'; import { Contract } from '@ethersproject/contracts'; import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api'; import { abiERC20 } from '@metamask/metamask-eth-abis'; -import type { CaipAssetType, CaipChainId } from '@metamask/utils'; -import { isCaipChainId, isStrictHexString, type Hex } from '@metamask/utils'; +import { isCaipChainId, isStrictHexString } from '@metamask/utils'; +import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; import { formatChainIdToCaip, @@ -16,11 +16,12 @@ import { METABRIDGE_ETHEREUM_ADDRESS, } from '../constants/bridge'; import { CHAIN_IDS } from '../constants/chains'; +import { SWAPS_CONTRACT_ADDRESSES } from '../constants/swaps'; import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP, SYMBOL_TO_SLIP44_MAP, - type SupportedSwapsNativeCurrencySymbols, } from '../constants/tokens'; +import type { SupportedSwapsNativeCurrencySymbols } from '../constants/tokens'; import type { BridgeAsset, BridgeControllerState, @@ -30,6 +31,27 @@ import type { } from '../types'; import { ChainId } from '../types'; +/** + * Checks whether the transaction is a cross-chain transaction by comparing the source and destination chainIds + * + * @param srcChainId - The source chainId + * @param destChainId - The destination chainId + * @returns Whether the transaction is a cross-chain transaction + */ +export const isCrossChain = ( + srcChainId: GenericQuoteRequest['srcChainId'], + destChainId?: GenericQuoteRequest['destChainId'], +) => { + try { + if (!destChainId) { + return false; + } + return formatChainIdToCaip(srcChainId) !== formatChainIdToCaip(destChainId); + } catch { + return false; + } +}; + export const getDefaultBridgeControllerState = (): BridgeControllerState => { return DEFAULT_BRIDGE_CONTROLLER_STATE; }; @@ -88,21 +110,30 @@ export const getNativeAssetForChainId = ( /** * A function to return the txParam data for setting allowance to 0 for USDT on Ethereum * + * @param destChainId - The destination chain ID * @returns The txParam data that will reset allowance to 0, combine it with the approval tx params received from Bridge API */ -export const getEthUsdtResetData = () => { +export const getEthUsdtResetData = ( + destChainId: GenericQuoteRequest['destChainId'], +) => { + const spenderAddress = isCrossChain(CHAIN_IDS.MAINNET, destChainId) + ? METABRIDGE_ETHEREUM_ADDRESS + : SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.MAINNET]; const UsdtContractInterface = new Contract(ETH_USDT_ADDRESS, abiERC20) .interface; const data = UsdtContractInterface.encodeFunctionData('approve', [ - METABRIDGE_ETHEREUM_ADDRESS, + spenderAddress, '0', ]); return data; }; -export const isEthUsdt = (chainId: Hex, address: string) => - chainId === CHAIN_IDS.MAINNET && +export const isEthUsdt = ( + chainId: GenericQuoteRequest['srcChainId'], + address: string, +) => + formatChainIdToDec(chainId) === ChainId.ETH && address.toLowerCase() === ETH_USDT_ADDRESS.toLowerCase(); export const sumHexes = (...hexStrings: string[]): Hex => { @@ -221,24 +252,3 @@ export const isEvmQuoteResponse = ( ): quoteResponse is QuoteResponse => { return !isNonEvmChainId(quoteResponse.quote.srcChainId); }; - -/** - * Checks whether the transaction is a cross-chain transaction by comparing the source and destination chainIds - * - * @param srcChainId - The source chainId - * @param destChainId - The destination chainId - * @returns Whether the transaction is a cross-chain transaction - */ -export const isCrossChain = ( - srcChainId: GenericQuoteRequest['srcChainId'], - destChainId?: GenericQuoteRequest['destChainId'], -) => { - try { - if (!destChainId) { - return false; - } - return formatChainIdToCaip(srcChainId) !== formatChainIdToCaip(destChainId); - } catch { - return false; - } -}; diff --git a/packages/bridge-controller/src/utils/caip-formatters.ts b/packages/bridge-controller/src/utils/caip-formatters.ts index 306b0eafbe2..498366c7e75 100644 --- a/packages/bridge-controller/src/utils/caip-formatters.ts +++ b/packages/bridge-controller/src/utils/caip-formatters.ts @@ -3,10 +3,7 @@ import { AddressZero } from '@ethersproject/constants'; import { convertHexToDecimal } from '@metamask/controller-utils'; import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api'; import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; -import type { CaipAssetType } from '@metamask/utils'; import { - type Hex, - type CaipChainId, isCaipChainId, isStrictHexString, parseCaipChainId, @@ -15,6 +12,7 @@ import { isCaipAssetType, CaipAssetTypeStruct, } from '@metamask/utils'; +import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; import { getNativeAssetForChainId, diff --git a/packages/bridge-controller/src/utils/fetch-server-events.ts b/packages/bridge-controller/src/utils/fetch-server-events.ts index 86996c73bd5..8bfe24a9aa0 100644 --- a/packages/bridge-controller/src/utils/fetch-server-events.ts +++ b/packages/bridge-controller/src/utils/fetch-server-events.ts @@ -17,9 +17,12 @@ export const fetchServerEvents = async ( fetchFn, ...requestOptions }: RequestInit & { - onMessage: (data: Record, eventName?: string) => void; + onMessage: ( + data: Record, + eventName?: string, + ) => Promise; onError?: (err: unknown) => void; - onClose?: () => void; + onClose?: () => void | Promise; fetchFn: typeof fetch; }, ) => { @@ -66,11 +69,11 @@ export const fetchServerEvents = async ( } if (dataLines.length > 0) { const parsedJSONData = JSON.parse(dataLines.join('\n')); - onMessage(parsedJSONData, eventName); + await onMessage(parsedJSONData, eventName); } } } - onClose?.(); + await onClose?.(); } catch (error) { onError?.(error); } finally { diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index 2411edeef49..6eeb268eb9b 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -188,6 +188,7 @@ describe('fetch', () => { mockBridgeQuotesNativeErc20.map((quote) => ({ ...quote, featureId: undefined, + resetApproval: undefined, })), ); expect(result.validationFailures).toStrictEqual([]); @@ -246,6 +247,7 @@ describe('fetch', () => { mockBridgeQuotesErc20Erc20.map((quote) => ({ ...quote, featureId: undefined, + resetApproval: undefined, })), ); expect(result.validationFailures).toStrictEqual([ @@ -322,6 +324,7 @@ describe('fetch', () => { mockBridgeQuotesErc20Erc20.map((quote) => ({ ...quote, featureId: undefined, + resetApproval: undefined, })), ); expect(result.validationFailures).toMatchInlineSnapshot(` @@ -393,6 +396,7 @@ describe('fetch', () => { mockBridgeQuotesNativeErc20.map((quote) => ({ ...quote, featureId: FeatureId.PERPS, + resetApproval: undefined, })), ); expect(result.validationFailures).toStrictEqual([]); diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index 95f994d14de..5a391c777f3 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -1,11 +1,13 @@ import { StructError } from '@metamask/superstruct'; import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; +import { getEthUsdtResetData } from './bridge'; import { formatAddressToCaipReference, formatChainIdToDec, } from './caip-formatters'; import { fetchServerEvents } from './fetch-server-events'; +import { isEvmTxData } from './trade-utils'; import type { FeatureId } from './validators'; import { validateQuoteResponse, validateSwapsTokenObject } from './validators'; import type { @@ -155,6 +157,15 @@ export async function fetchBridgeQuotes( .map((quote) => ({ ...quote, featureId: featureId ?? undefined, + // Append the reset approval data to the quote response if the request + // has resetApproval set to true and the quote has an approval + resetApproval: + request.resetApproval && quote.approval && isEvmTxData(quote.approval) + ? { + ...quote.approval, + data: getEthUsdtResetData(request.destChainId), + } + : undefined, })); const validationFailures = Array.from(uniqueValidationFailures); @@ -196,22 +207,21 @@ const fetchAssetPricesForCurrency = async (request: { return {}; } - return Object.entries(priceApiResponse).reduce( - (acc, [assetId, currencyToPrice]) => { - if (!currencyToPrice) { - return acc; - } - if (!acc[assetId as CaipAssetType]) { - acc[assetId as CaipAssetType] = {}; - } - if (currencyToPrice[currency]) { - acc[assetId as CaipAssetType][currency] = - currencyToPrice[currency].toString(); - } + return Object.entries(priceApiResponse).reduce< + Record + >((acc, [assetId, currencyToPrice]) => { + if (!currencyToPrice) { return acc; - }, - {} as Record, - ); + } + if (!acc[assetId as CaipAssetType]) { + acc[assetId as CaipAssetType] = {}; + } + if (currencyToPrice[currency]) { + acc[assetId as CaipAssetType][currency] = + currencyToPrice[currency].toString(); + } + return acc; + }, {}); }; /** @@ -235,23 +245,22 @@ export const fetchAssetPrices = async ( await fetchAssetPricesForCurrency({ ...args, currency }), ), ).then((priceApiResponse) => { - return priceApiResponse.reduce( - (acc, result) => { - if (result.status === 'fulfilled') { - Object.entries(result.value).forEach(([assetId, currencyToPrice]) => { - const existingPrices = acc[assetId as CaipAssetType]; - if (!existingPrices) { - acc[assetId as CaipAssetType] = {}; - } - Object.entries(currencyToPrice).forEach(([currency, price]) => { - acc[assetId as CaipAssetType][currency] = price; - }); + return priceApiResponse.reduce< + Record + >((acc, result) => { + if (result.status === 'fulfilled') { + Object.entries(result.value).forEach(([assetId, currencyToPrice]) => { + const existingPrices = acc[assetId as CaipAssetType]; + if (!existingPrices) { + acc[assetId as CaipAssetType] = {}; + } + Object.entries(currencyToPrice).forEach(([currency, price]) => { + acc[assetId as CaipAssetType][currency] = price; }); - } - return acc; - }, - {} as Record, - ); + }); + } + return acc; + }, {}); }); return combinedPrices; @@ -271,7 +280,7 @@ export const fetchAssetPrices = async ( * @param serverEventHandlers.onValidQuoteReceived - The function to handle valid quotes * @param serverEventHandlers.onClose - The function to run when the stream is closed and there are no thrown errors * @param clientVersion - The client version for metrics (optional) - * @returns A list of bridge tx quotes + * @returns A list of bridge tx quote promises */ export async function fetchBridgeQuoteStream( fetchFn: FetchFunction, @@ -280,7 +289,7 @@ export async function fetchBridgeQuoteStream( clientId: string, bridgeApiBaseUrl: string, serverEventHandlers: { - onClose: () => void; + onClose: () => void | Promise; onValidationFailure: (validationFailures: string[]) => void; onValidQuoteReceived: (quotes: QuoteResponse) => Promise; }, @@ -288,14 +297,23 @@ export async function fetchBridgeQuoteStream( ): Promise { const queryParams = formatQueryParams(request); - const onMessage = (quoteResponse: unknown) => { + const onMessage = async (quoteResponse: unknown): Promise => { const uniqueValidationFailures: Set = new Set([]); try { if (validateQuoteResponse(quoteResponse)) { - // eslint-disable-next-line promise/catch-or-return, @typescript-eslint/no-floating-promises - serverEventHandlers.onValidQuoteReceived(quoteResponse).then((v) => { - return v; + return await serverEventHandlers.onValidQuoteReceived({ + ...quoteResponse, + // Append the reset approval data to the quote response if the request has resetApproval set to true and the quote has an approval + resetApproval: + request.resetApproval && + quoteResponse.approval && + isEvmTxData(quoteResponse.approval) + ? { + ...quoteResponse.approval, + data: getEthUsdtResetData(request.destChainId), + } + : undefined, }); } } catch (error) { @@ -314,12 +332,12 @@ export async function fetchBridgeQuoteStream( const validationFailures = Array.from(uniqueValidationFailures); if (uniqueValidationFailures.size > 0) { console.warn('Quote validation failed', validationFailures); - serverEventHandlers.onValidationFailure(validationFailures); - } else { - // Rethrow any unexpected errors - throw error; + return serverEventHandlers.onValidationFailure(validationFailures); } + // Rethrow any unexpected errors + throw error; } + return undefined; }; const urlStream = `${bridgeApiBaseUrl}/getQuoteStream?${queryParams}`; @@ -334,8 +352,8 @@ export async function fetchBridgeQuoteStream( // Rethrow error to prevent silent fetch failures throw e; }, - onClose: () => { - serverEventHandlers.onClose(); + onClose: async () => { + await serverEventHandlers.onClose(); }, fetchFn, }); diff --git a/packages/bridge-controller/src/utils/metrics/constants.ts b/packages/bridge-controller/src/utils/metrics/constants.ts index 5cbf0cbd9c7..bf5da4454f3 100644 --- a/packages/bridge-controller/src/utils/metrics/constants.ts +++ b/packages/bridge-controller/src/utils/metrics/constants.ts @@ -26,6 +26,7 @@ export enum AbortReason { NewQuoteRequest = 'New Quote Request', QuoteRequestUpdated = 'Quote Request Updated', ResetState = 'Reset controller state', + TransactionSubmitted = 'Transaction submitted', } /** diff --git a/packages/bridge-controller/src/utils/metrics/properties.ts b/packages/bridge-controller/src/utils/metrics/properties.ts index 65fcc37dc19..d25b700ffac 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -8,11 +8,13 @@ import type { RequestParams, } from './types'; import { DEFAULT_BRIDGE_CONTROLLER_STATE } from '../../constants/bridge'; -import type { QuoteMetadata, QuoteResponse, TxData } from '../../types'; -import { - ChainId, - type GenericQuoteRequest, - type QuoteRequest, +import { ChainId } from '../../types'; +import type { + GenericQuoteRequest, + QuoteMetadata, + QuoteRequest, + QuoteResponse, + TxData, } from '../../types'; import { getNativeAssetForChainId, isCrossChain } from '../bridge'; import { diff --git a/packages/bridge-controller/src/utils/metrics/types.ts b/packages/bridge-controller/src/utils/metrics/types.ts index 1504c332063..a149d721e35 100644 --- a/packages/bridge-controller/src/utils/metrics/types.ts +++ b/packages/bridge-controller/src/utils/metrics/types.ts @@ -107,7 +107,7 @@ export type RequiredEventContextFromClient = { } & Pick; [UnifiedSwapBridgeEventName.QuotesRequested]: Pick< RequestMetadata, - 'stx_enabled' + 'stx_enabled' | 'usd_amount_source' > & { token_symbol_source: RequestParams['token_symbol_source']; token_symbol_destination: RequestParams['token_symbol_destination']; diff --git a/packages/bridge-controller/src/utils/quote-fees.ts b/packages/bridge-controller/src/utils/quote-fees.ts index e67f061219a..e9c58fb4b1a 100644 --- a/packages/bridge-controller/src/utils/quote-fees.ts +++ b/packages/bridge-controller/src/utils/quote-fees.ts @@ -98,7 +98,7 @@ const appendL1GasFees = async ( const appendNonEvmFees = async ( quotes: QuoteResponse[], messenger: BridgeControllerMessenger, - selectedAccount: InternalAccount, + selectedAccount?: InternalAccount, ): Promise<(QuoteResponse & NonEvmFees)[] | undefined> => { if ( quotes.some(({ quote: { srcChainId } }) => !isNonEvmChainId(srcChainId)) @@ -131,9 +131,9 @@ const appendNonEvmFees = async ( const response = (await messenger.call( 'SnapController:handleRequest', computeFeeRequest( - selectedAccount.metadata.snap?.id, + selectedAccount?.metadata?.snap?.id, transaction, - selectedAccount.id, + selectedAccount?.id, scope, options, ), @@ -178,8 +178,6 @@ const appendNonEvmFees = async ( >((acc, result) => { if (result.status === 'fulfilled' && result.value) { acc.push(result.value); - } else if (result.status === 'rejected') { - console.error('Error calculating non-EVM fees for quote', result.reason); } return acc; }, []); @@ -200,7 +198,7 @@ export const appendFeesToQuotes = async ( quotes: QuoteResponse[], messenger: BridgeControllerMessenger, getLayer1GasFee: typeof TransactionController.prototype.getLayer1GasFee, - selectedAccount: InternalAccount, + selectedAccount?: InternalAccount, ): Promise<(QuoteResponse & L1GasFees & NonEvmFees)[]> => { // Safe to cast: appendL1GasFees checks if all quotes are EVM and returns undefined otherwise const quotesWithL1GasFees = await appendL1GasFees( diff --git a/packages/bridge-controller/src/utils/quote.test.ts b/packages/bridge-controller/src/utils/quote.test.ts index 3db7aa2383b..9871c3942fc 100644 --- a/packages/bridge-controller/src/utils/quote.test.ts +++ b/packages/bridge-controller/src/utils/quote.test.ts @@ -436,7 +436,6 @@ describe('Quote Metadata Utils', () => { bridgeQuote: mockBridgeQuote, estimatedBaseFeeInDecGwei: '50', maxFeePerGasInDecGwei: '100', - maxPriorityFeePerGasInDecGwei: '2', exchangeRate: '2000', usdExchangeRate: '1500', }); @@ -444,19 +443,19 @@ describe('Quote Metadata Utils', () => { expect(result).toMatchInlineSnapshot(` Object { "effective": Object { - "amount": "0.003584", - "usd": "5.376", - "valueInCurrency": "7.168", + "amount": "0.00345", + "usd": "5.175", + "valueInCurrency": "6.9", }, "max": Object { - "amount": "0.006934", - "usd": "10.401", - "valueInCurrency": "13.868", + "amount": "0.0068", + "usd": "10.2", + "valueInCurrency": "13.6", }, "total": Object { - "amount": "0.003584", - "usd": "5.376", - "valueInCurrency": "7.168", + "amount": "0.00345", + "usd": "5.175", + "valueInCurrency": "6.9", }, } `); @@ -476,7 +475,6 @@ describe('Quote Metadata Utils', () => { } as QuoteResponse & L1GasFees, estimatedBaseFeeInDecGwei: '50', maxFeePerGasInDecGwei: '100', - maxPriorityFeePerGasInDecGwei: '2', exchangeRate: '2000', usdExchangeRate: '1500', }); @@ -484,19 +482,19 @@ describe('Quote Metadata Utils', () => { expect(result).toMatchInlineSnapshot(` Object { "effective": Object { - "amount": "0.00166", - "usd": "2.49", - "valueInCurrency": "3.32", + "amount": "0.0016", + "usd": "2.4", + "valueInCurrency": "3.2", }, "max": Object { - "amount": "0.006934", - "usd": "10.401", - "valueInCurrency": "13.868", + "amount": "0.0068", + "usd": "10.2", + "valueInCurrency": "13.6", }, "total": Object { - "amount": "0.003584", - "usd": "5.376", - "valueInCurrency": "7.168", + "amount": "0.00345", + "usd": "5.175", + "valueInCurrency": "6.9", }, } `); @@ -512,7 +510,6 @@ describe('Quote Metadata Utils', () => { bridgeQuote: mockBridgeQuote, estimatedBaseFeeInDecGwei: '50', maxFeePerGasInDecGwei: '100', - maxPriorityFeePerGasInDecGwei: '2', exchangeRate: undefined, usdExchangeRate: undefined, }); @@ -530,7 +527,6 @@ describe('Quote Metadata Utils', () => { bridgeQuote: mockBridgeQuote, estimatedBaseFeeInDecGwei: '50', maxFeePerGasInDecGwei: '100', - maxPriorityFeePerGasInDecGwei: '2', exchangeRate: '2000', usdExchangeRate: undefined, }); @@ -546,7 +542,6 @@ describe('Quote Metadata Utils', () => { bridgeQuote: mockBridgeQuote, estimatedBaseFeeInDecGwei: '50', maxFeePerGasInDecGwei: '100', - maxPriorityFeePerGasInDecGwei: '2', exchangeRate: undefined, usdExchangeRate: '1500', }); @@ -570,7 +565,6 @@ describe('Quote Metadata Utils', () => { bridgeQuote: zeroGasQuote, estimatedBaseFeeInDecGwei: '50', maxFeePerGasInDecGwei: '100', - maxPriorityFeePerGasInDecGwei: '2', exchangeRate: '2000', usdExchangeRate: '1500', }); @@ -594,7 +588,6 @@ describe('Quote Metadata Utils', () => { bridgeQuote: noApprovalQuote, estimatedBaseFeeInDecGwei: '50', maxFeePerGasInDecGwei: '100', - maxPriorityFeePerGasInDecGwei: '2', exchangeRate: '2000', usdExchangeRate: '1500', }); @@ -619,7 +612,6 @@ describe('Quote Metadata Utils', () => { bridgeQuote: noGasLimitQuote, estimatedBaseFeeInDecGwei: '50', maxFeePerGasInDecGwei: '100', - maxPriorityFeePerGasInDecGwei: '2', exchangeRate: '2000', usdExchangeRate: '1500', }); @@ -641,7 +633,6 @@ describe('Quote Metadata Utils', () => { bridgeQuote: largeGasQuote, estimatedBaseFeeInDecGwei: '100', maxFeePerGasInDecGwei: '200', - maxPriorityFeePerGasInDecGwei: '10', exchangeRate: '3000', usdExchangeRate: '2500', }); diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts index ab46fa9f55f..66c35d7758b 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -187,31 +187,28 @@ export const calcRelayerFee = ( const calcTotalGasFee = ({ approvalGasLimit, + resetApprovalGasLimit, tradeGasLimit, l1GasFeesInHexWei, feePerGasInDecGwei, - priorityFeePerGasInDecGwei, nativeToDisplayCurrencyExchangeRate, nativeToUsdExchangeRate, }: { approvalGasLimit?: number | null; + resetApprovalGasLimit?: number | null; tradeGasLimit?: number | null; l1GasFeesInHexWei?: string | null; feePerGasInDecGwei: string; - priorityFeePerGasInDecGwei: string; nativeToDisplayCurrencyExchangeRate?: string; nativeToUsdExchangeRate?: string; }) => { - const totalGasLimitInDec = new BigNumber( - tradeGasLimit?.toString() ?? '0', - ).plus(approvalGasLimit?.toString() ?? '0'); + const totalGasLimitInDec = new BigNumber(tradeGasLimit?.toString() ?? '0') + .plus(approvalGasLimit?.toString() ?? '0') + .plus(resetApprovalGasLimit?.toString() ?? '0'); - const totalFeePerGasInDecGwei = new BigNumber(feePerGasInDecGwei).plus( - priorityFeePerGasInDecGwei, - ); const l1GasFeesInDecGWei = weiHexToGweiDec(toHex(l1GasFeesInHexWei ?? '0')); const gasFeesInDecGwei = totalGasLimitInDec - .times(totalFeePerGasInDecGwei) + .times(feePerGasInDecGwei) .plus(l1GasFeesInDecGWei); const gasFeesInDecEth = gasFeesInDecGwei.times(new BigNumber(10).pow(-9)); @@ -230,17 +227,15 @@ const calcTotalGasFee = ({ }; export const calcEstimatedAndMaxTotalGasFee = ({ - bridgeQuote: { approval, trade, l1GasFeesInHexWei }, + bridgeQuote: { approval, trade, l1GasFeesInHexWei, resetApproval }, estimatedBaseFeeInDecGwei, maxFeePerGasInDecGwei, - maxPriorityFeePerGasInDecGwei, exchangeRate: nativeToDisplayCurrencyExchangeRate, usdExchangeRate: nativeToUsdExchangeRate, }: { bridgeQuote: QuoteResponse & L1GasFees; estimatedBaseFeeInDecGwei: string; maxFeePerGasInDecGwei: string; - maxPriorityFeePerGasInDecGwei: string; } & ExchangeRate): QuoteMetadata['gasFee'] => { // Estimated gas fees spent after receiving refunds, this is shown to the user const { @@ -250,10 +245,11 @@ export const calcEstimatedAndMaxTotalGasFee = ({ } = calcTotalGasFee({ // Fallback to gasLimit if effectiveGas is not available approvalGasLimit: approval?.effectiveGas ?? approval?.gasLimit, + resetApprovalGasLimit: + resetApproval?.effectiveGas ?? resetApproval?.gasLimit, tradeGasLimit: trade?.effectiveGas ?? trade?.gasLimit, l1GasFeesInHexWei, feePerGasInDecGwei: estimatedBaseFeeInDecGwei, - priorityFeePerGasInDecGwei: maxPriorityFeePerGasInDecGwei, nativeToDisplayCurrencyExchangeRate, nativeToUsdExchangeRate, }); @@ -261,10 +257,10 @@ export const calcEstimatedAndMaxTotalGasFee = ({ // Estimated total gas fee, including refunded fees (medium) const { amount, valueInCurrency, usd } = calcTotalGasFee({ approvalGasLimit: approval?.gasLimit, + resetApprovalGasLimit: resetApproval?.gasLimit, tradeGasLimit: trade?.gasLimit, l1GasFeesInHexWei, feePerGasInDecGwei: estimatedBaseFeeInDecGwei, - priorityFeePerGasInDecGwei: maxPriorityFeePerGasInDecGwei, nativeToDisplayCurrencyExchangeRate, nativeToUsdExchangeRate, }); @@ -276,10 +272,10 @@ export const calcEstimatedAndMaxTotalGasFee = ({ usd: usdMax, } = calcTotalGasFee({ approvalGasLimit: approval?.gasLimit, + resetApprovalGasLimit: resetApproval?.gasLimit, tradeGasLimit: trade?.gasLimit, l1GasFeesInHexWei, feePerGasInDecGwei: maxFeePerGasInDecGwei, - priorityFeePerGasInDecGwei: maxPriorityFeePerGasInDecGwei, nativeToDisplayCurrencyExchangeRate, nativeToUsdExchangeRate, }); diff --git a/packages/bridge-controller/src/utils/swaps.test.ts b/packages/bridge-controller/src/utils/swaps.test.ts new file mode 100644 index 00000000000..7d64e320a08 --- /dev/null +++ b/packages/bridge-controller/src/utils/swaps.test.ts @@ -0,0 +1,231 @@ +import type { Hex } from '@metamask/utils'; + +import { + API_BASE_URL, + fetchTokens, + getSwapsContractAddress, + isValidSwapsContractAddress, +} from './swaps'; +import type { SwapsToken } from './swaps'; +import { CHAIN_IDS } from '../constants/chains'; +import { + ALLOWED_CONTRACT_ADDRESSES, + SWAPS_CONTRACT_ADDRESSES, +} from '../constants/swaps'; +import { + DEFAULT_TOKEN_ADDRESS, + SWAPS_CHAINID_DEFAULT_TOKEN_MAP, +} from '../constants/tokens'; +import type { FetchFunction } from '../types'; + +describe('Swaps utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('isValidSwapsContractAddress', () => { + it('returns true for valid swaps contract address', () => { + const contract = SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.MAINNET]; + expect(isValidSwapsContractAddress(CHAIN_IDS.MAINNET, contract)).toBe( + true, + ); + }); + + it('returns true for any allowed contract address', () => { + const allowedAddresses = ALLOWED_CONTRACT_ADDRESSES[CHAIN_IDS.MAINNET]; + allowedAddresses.forEach((address) => { + expect(isValidSwapsContractAddress(CHAIN_IDS.MAINNET, address)).toBe( + true, + ); + }); + }); + + it('returns true for contract address with different case', () => { + const contract = SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.MAINNET]; + const upperCaseContract = contract.toUpperCase() as Hex; + const mixedCaseContract = + '0x881D40237659C251811CEC9C364EF91DC08D300C' as Hex; + + expect( + isValidSwapsContractAddress(CHAIN_IDS.MAINNET, upperCaseContract), + ).toBe(true); + expect( + isValidSwapsContractAddress(CHAIN_IDS.MAINNET, mixedCaseContract), + ).toBe(true); + }); + + it('returns false for invalid contract address', () => { + const invalidContract = + '0x1234567890123456789012345678901234567890' as Hex; + expect( + isValidSwapsContractAddress(CHAIN_IDS.MAINNET, invalidContract), + ).toBe(false); + }); + + it('returns false when contract is undefined', () => { + expect(isValidSwapsContractAddress(CHAIN_IDS.MAINNET, undefined)).toBe( + false, + ); + }); + + it('returns false for unsupported chain ID', () => { + const contract = SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.MAINNET]; + const unsupportedChainId = '0x999' as Hex; + expect(isValidSwapsContractAddress(unsupportedChainId, contract)).toBe( + false, + ); + }); + + it('returns false when chain ID is not in ALLOWED_CONTRACT_ADDRESSES', () => { + const contract = '0x881d40237659c251811cec9c364ef91dc08d300c' as Hex; + const unknownChainId = '0xabc' as Hex; + expect(isValidSwapsContractAddress(unknownChainId, contract)).toBe(false); + }); + + it('returns false for empty contract address', () => { + expect(isValidSwapsContractAddress(CHAIN_IDS.MAINNET, '' as Hex)).toBe( + false, + ); + }); + + it('returns false for contract address on wrong chain', () => { + const mainnetContract = SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.MAINNET]; + expect(isValidSwapsContractAddress(CHAIN_IDS.BSC, mainnetContract)).toBe( + false, + ); + }); + + it('validates all wrapped token addresses', () => { + // Test that wrapped token addresses are also in the allowed list + Object.keys(ALLOWED_CONTRACT_ADDRESSES).forEach((chainId) => { + const allowedAddresses = ALLOWED_CONTRACT_ADDRESSES[chainId as Hex]; + // Each chain should have at least the swaps contract and wrapped token + expect(allowedAddresses.length).toBeGreaterThanOrEqual(2); + + // Verify each allowed address validates correctly + allowedAddresses.forEach((address) => { + expect(isValidSwapsContractAddress(chainId as Hex, address)).toBe( + true, + ); + }); + }); + }); + }); + + describe('getSwapsContractAddress', () => { + it('returns correct swaps contract address', () => { + expect(getSwapsContractAddress(CHAIN_IDS.MAINNET)).toBe( + '0x881d40237659c251811cec9c364ef91dc08d300c', + ); + }); + + it('returns undefined for unsupported chain ID', () => { + const unsupportedChainId = '0x999' as Hex; + expect(getSwapsContractAddress(unsupportedChainId)).toBeUndefined(); + }); + + it('returns addresses that match the SWAPS_CONTRACT_ADDRESSES constant', () => { + Object.keys(SWAPS_CONTRACT_ADDRESSES).forEach((chainId) => { + const address = getSwapsContractAddress(chainId as Hex); + expect(address).toBe(SWAPS_CONTRACT_ADDRESSES[chainId as Hex]); + }); + }); + }); + + describe('fetchTokens', () => { + const mockTokens: SwapsToken[] = [ + { + address: DEFAULT_TOKEN_ADDRESS, + symbol: 'ETH', + name: 'Ether', + decimals: 18, + iconUrl: 'https://example.com/eth.png', + }, + { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + iconUrl: 'https://example.com/usdc.png', + }, + { + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + iconUrl: 'https://example.com/usdt.png', + }, + ]; + + it('fetches and returns tokens with native token', async () => { + const mockFetchFn = jest.fn().mockResolvedValue(mockTokens); + + const result = await fetchTokens( + CHAIN_IDS.MAINNET, + mockFetchFn as unknown as FetchFunction, + ); + + expect(mockFetchFn).toHaveBeenCalledWith( + `${API_BASE_URL}/networks/1/tokens`, + { + headers: undefined, + }, + ); + + // Should filter out the default token address and add native token + expect(result).toHaveLength(3); + expect(result[0].address).toBe( + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + ); + expect(result[1].address).toBe( + '0xdac17f958d2ee523a2206206994597c13d831ec7', + ); + expect(result[2]).toStrictEqual( + SWAPS_CHAINID_DEFAULT_TOKEN_MAP[CHAIN_IDS.MAINNET], + ); + }); + + it('includes client ID header when provided', async () => { + const mockFetchFn = jest.fn().mockResolvedValue(mockTokens); + const clientId = 'test-client-id'; + + await fetchTokens( + CHAIN_IDS.MAINNET, + mockFetchFn as unknown as FetchFunction, + clientId, + ); + + expect(mockFetchFn).toHaveBeenCalledWith( + `${API_BASE_URL}/networks/1/tokens`, + { + headers: { 'X-Client-Id': clientId }, + }, + ); + }); + + it('does not include client ID header when not provided', async () => { + const mockFetchFn = jest.fn().mockResolvedValue(mockTokens); + + await fetchTokens( + CHAIN_IDS.MAINNET, + mockFetchFn as unknown as FetchFunction, + ); + + expect(mockFetchFn).toHaveBeenCalledWith( + `${API_BASE_URL}/networks/1/tokens`, + { + headers: undefined, + }, + ); + }); + + it('propagates fetch errors', async () => { + const mockError = new Error('Network error'); + const mockFetchFn = jest.fn().mockRejectedValue(mockError); + + await expect( + fetchTokens(CHAIN_IDS.MAINNET, mockFetchFn as unknown as FetchFunction), + ).rejects.toThrow('Network error'); + }); + }); +}); diff --git a/packages/bridge-controller/src/utils/swaps.ts b/packages/bridge-controller/src/utils/swaps.ts new file mode 100644 index 00000000000..ce6352da13d --- /dev/null +++ b/packages/bridge-controller/src/utils/swaps.ts @@ -0,0 +1,108 @@ +import type { Hex } from '@metamask/utils'; + +import { formatChainIdToDec } from './caip-formatters'; +import { CHAIN_IDS } from '../constants/chains'; +import { + ALLOWED_CONTRACT_ADDRESSES, + SWAPS_CONTRACT_ADDRESSES, +} from '../constants/swaps'; +import { + DEFAULT_TOKEN_ADDRESS, + SWAPS_CHAINID_DEFAULT_TOKEN_MAP, +} from '../constants/tokens'; +import type { FetchFunction } from '../types'; + +/** + * Checks if the given contract address is valid for the given chain ID. + * + * @param chainId - The chain ID. + * @param contract - The contract address. + * @returns True if the contract address is valid, false otherwise. + */ +export function isValidSwapsContractAddress( + chainId: Hex, + contract: Hex | undefined, +): boolean { + if (!contract || !ALLOWED_CONTRACT_ADDRESSES[chainId]) { + return false; + } + return ALLOWED_CONTRACT_ADDRESSES[chainId].some( + (allowedContract) => + contract.toLowerCase() === allowedContract.toLowerCase(), + ); +} + +/** + * Gets the swaps contract address for the given chain ID. + * + * @param chainId - The chain ID. + * @returns The swaps contract address. + */ +export function getSwapsContractAddress(chainId: Hex): string { + return SWAPS_CONTRACT_ADDRESSES[chainId]; +} + +/** + * Gets the client ID header. + * + * @param clientId - The client ID. + * @returns The client ID header. + */ +function getClientIdHeader(clientId?: string) { + if (!clientId) { + return undefined; + } + return { + 'X-Client-Id': clientId, + }; +} + +export const API_BASE_URL = 'https://swap.api.cx.metamask.io'; +export const DEV_BASE_URL = 'https://swap.dev-api.cx.metamask.io'; + +export type SwapsToken = { + address: string; + symbol: string; + name?: string; + decimals: number; + iconUrl?: string; + occurrences?: number; +}; + +/** + * Fetches token metadata from API URL. + * + * @param chainId - Current chainId. + * @param fetchFn - Fetch function. + * @param clientId - Client id. + * @returns Promise resolving to an object containing token metadata. + */ +export async function fetchTokens( + chainId: Hex, + fetchFn: FetchFunction, + clientId?: string, +): Promise { + const [apiChainId, apiBaseUrl] = + chainId === CHAIN_IDS.LOCALHOST + ? [CHAIN_IDS.MAINNET, DEV_BASE_URL] + : [chainId, API_BASE_URL]; + + const apiDecimalChainId = formatChainIdToDec(apiChainId); + const tokenUrl = `${apiBaseUrl}/networks/${apiDecimalChainId}/tokens`; + + const tokens: SwapsToken[] = await fetchFn(tokenUrl, { + headers: getClientIdHeader(clientId), + }); + + const filteredTokens = tokens.filter((token) => { + return token.address !== DEFAULT_TOKEN_ADDRESS; + }); + + const nativeSwapsToken = + SWAPS_CHAINID_DEFAULT_TOKEN_MAP[ + chainId as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP + ]; + + filteredTokens.push(nativeSwapsToken); + return filteredTokens; +} diff --git a/packages/bridge-controller/src/utils/trade-utils.test.ts b/packages/bridge-controller/src/utils/trade-utils.test.ts index 0dbfeaddcbe..7cc11fc6ce7 100644 --- a/packages/bridge-controller/src/utils/trade-utils.test.ts +++ b/packages/bridge-controller/src/utils/trade-utils.test.ts @@ -3,8 +3,8 @@ import { isEvmTxData, isBitcoinTrade, isTronTrade, - type Trade, } from './trade-utils'; +import type { Trade } from './trade-utils'; import type { BitcoinTradeData, TronTradeData, TxData } from '../types'; describe('Trade utils', () => { diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 686ee22c04e..715e8db21b3 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/transaction-controller` from `^62.4.0` to `^62.5.0` ([#7325](https://github.com/MetaMask/core/pull/7325)) + +## [64.0.1] + +### Fixed + +- Fix MAX native token swap failing with "insufficient gas" when STX is off by using quote's `txFee` instead of re-estimating gas when `gasIncluded` is true ([#7306](https://github.com/MetaMask/core/pull/7306)) + +## [64.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` from `^63.2.0` to `^64.0.0` ([#7295](https://github.com/MetaMask/core/pull/7295)) +- Improve type safety by replacing tx data type assertions with type predicates ([#7228](https://github.com/MetaMask/core/pull/7228)) +- Submit `resetApproval` tx before the tx approval if it is included in the quoteResponse ([#7228](https://github.com/MetaMask/core/pull/7228)) +- Bump `@metamask/network-controller` from `^26.0.0` to `^27.0.0` ([#7258](https://github.com/MetaMask/core/pull/7258)) +- Bump `@metamask/transaction-controller` from `^62.3.0` to `^62.4.0` ([#7257](https://github.com/MetaMask/core/pull/7257), [#7289](https://github.com/MetaMask/core/pull/7289)) + +## [63.1.0] + +### Changed + +- Bump `@metamask/bridge-controller` from `^63.1.0` to `^63.2.0` ([#7245](https://github.com/MetaMask/core/pull/7245)) +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7220](https://github.com/MetaMask/core/pull/7220), [#7236](https://github.com/MetaMask/core/pull/7236)) + - The dependencies moved are: + - `@metamask/accounts-controller` (^35.0.0) + - `@metamask/bridge-controller` (^63.0.0) + - `@metamask/gas-fee-controller` (^26.0.0) + - `@metamask/network-controller` (^26.0.0) + - `@metamask/snaps-controllers` (^14.0.1) + - `@metamask/transaction-controller` (^62.3.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. +- Bump `@metamask/bridge-controller` from `^63.0.0` to `^63.1.0` ([#7238](https://github.com/MetaMask/core/pull/7238)) + +### Removed + +- Remove direct QuotesReceived event publishing to avoid race conditions that can happen when clients navigate and reset state. Update `submitTx` to accept quotesReceivedContext (replace isLoading/warnings) and propagate context to the BridgeController through the `stopPollingForQuotes call ([#7242](https://github.com/MetaMask/core/pull/7242)) + +## [63.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` from `^62.0.0` to `^63.0.0` ([#7207](https://github.com/MetaMask/core/pull/7207)) + ## [62.0.0] ### Changed @@ -771,7 +819,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@62.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@64.0.1...HEAD +[64.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@64.0.0...@metamask/bridge-status-controller@64.0.1 +[64.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@63.1.0...@metamask/bridge-status-controller@64.0.0 +[63.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@63.0.0...@metamask/bridge-status-controller@63.1.0 +[63.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@62.0.0...@metamask/bridge-status-controller@63.0.0 [62.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@61.0.0...@metamask/bridge-status-controller@62.0.0 [61.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@60.1.0...@metamask/bridge-status-controller@61.0.0 [60.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@60.0.0...@metamask/bridge-status-controller@60.1.0 diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index 2d3f52b89a8..3896e57ea23 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-status-controller", - "version": "62.0.0", + "version": "64.0.1", "description": "Manages bridge-related status fetching functionality for MetaMask", "keywords": [ "MetaMask", @@ -48,22 +48,22 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/accounts-controller": "^35.0.0", "@metamask/base-controller": "^9.0.0", + "@metamask/bridge-controller": "^64.0.0", "@metamask/controller-utils": "^11.16.0", + "@metamask/gas-fee-controller": "^26.0.0", + "@metamask/network-controller": "^27.0.0", "@metamask/polling-controller": "^16.0.0", + "@metamask/snaps-controllers": "^14.0.1", "@metamask/superstruct": "^3.1.0", + "@metamask/transaction-controller": "^62.5.0", "@metamask/utils": "^11.8.1", "bignumber.js": "^9.1.2", "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/accounts-controller": "^35.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/bridge-controller": "^62.0.0", - "@metamask/gas-fee-controller": "^26.0.0", - "@metamask/network-controller": "^26.0.0", - "@metamask/snaps-controllers": "^14.0.1", - "@metamask/transaction-controller": "^62.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -76,14 +76,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/accounts-controller": "^35.0.0", - "@metamask/bridge-controller": "^62.0.0", - "@metamask/gas-fee-controller": "^26.0.0", - "@metamask/network-controller": "^26.0.0", - "@metamask/snaps-controllers": "^14.0.0", - "@metamask/transaction-controller": "^62.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap index 7755ebe12b3..f8e2980b1aa 100644 --- a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -531,6 +531,8 @@ exports[`BridgeStatusController submitTx: EVM bridge should call handleMobileHar Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -764,6 +766,8 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -997,6 +1001,8 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -1081,7 +1087,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and publish QuotesReceived event if quotes are still loading 1`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 1`] = ` Object { "batchId": "batchId1", "chainId": "0xa4b1", @@ -1105,7 +1111,7 @@ Object { } `; -exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and publish QuotesReceived event if quotes are still loading 2`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 2`] = ` Object { "account": "0xaccount1", "approvalTxId": undefined, @@ -1227,7 +1233,7 @@ Object { } `; -exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and publish QuotesReceived event if quotes are still loading 3`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 3`] = ` Array [ Array [ Object { @@ -1245,7 +1251,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and publish QuotesReceived event if quotes are still loading 4`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 4`] = ` Array [ Array [ Object { @@ -1279,13 +1285,12 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and publish QuotesReceived event if quotes are still loading 5`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 5`] = ` Array [ Array [ - "BridgeController:trackUnifiedSwapBridgeEvent", - "Unified SwapBridge Quotes Received", + "BridgeController:stopPollingForQuotes", + "Transaction submitted", Object { - "action_type": "swapbridge-v1", "best_quote_provider": "lifi_across", "can_submit": true, "gas_included": false, @@ -1300,9 +1305,6 @@ Array [ ], }, ], - Array [ - "BridgeController:stopPollingForQuotes", - ], Array [ "AccountsController:getAccountByAddress", "0xaccount1", @@ -1496,6 +1498,8 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -1729,6 +1733,8 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -1962,11 +1968,11 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance Array [ Array [ Object { - "chainId": "0xa4b1", + "chainId": "0x1", "networkClientId": "arbitrum-client-id", "transactionParams": Object { - "chainId": "0xa4b1", - "data": "0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000000000", + "chainId": "0x1", + "data": "0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000", "from": "0xaccount1", "gas": "21000", "gasLimit": "21000", @@ -2012,8 +2018,8 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance Array [ Array [ Object { - "chainId": "0xa4b1", - "data": "0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000000000", + "chainId": "0x1", + "data": "0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000", "from": "0xaccount1", "gas": "0x5208", "gasLimit": "21000", @@ -2077,6 +2083,8 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -2105,18 +2113,13 @@ Array [ "usd_quoted_return": 0, }, ], - Array [ - "BridgeController:getBridgeERC20Allowance", - "0x0000000000000000000000000000000000000000", - "0xa4b1", - ], Array [ "AccountsController:getAccountByAddress", "0xaccount1", ], Array [ "NetworkController:findNetworkClientIdByChainId", - "0xa4b1", + "0x1", ], Array [ "GasFeeController:getState", @@ -2349,6 +2352,8 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -2602,6 +2607,8 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -2676,6 +2683,8 @@ exports[`BridgeStatusController submitTx: EVM bridge should throw an error if ap Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -2747,6 +2756,8 @@ exports[`BridgeStatusController submitTx: EVM bridge should throw an error if ap Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -2792,6 +2803,25 @@ Array [ ] `; +exports[`BridgeStatusController submitTx: EVM swap should estimate gas when gasIncluded is false and STX is off 1`] = ` +Object { + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 1`] = ` Object { "batchId": "batchId1", @@ -3015,6 +3045,8 @@ exports[`BridgeStatusController submitTx: EVM swap should handle smart transacti Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -3105,6 +3137,8 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -3286,6 +3320,8 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -3331,10 +3367,179 @@ Array [ ] `; +exports[`BridgeStatusController submitTx: EVM swap should use batch path when gasIncluded7702 is true regardless of STX setting 1`] = ` +Object { + "batchId": "batchId1", + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (Max native token swap) 1`] = ` +Object { + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (Max native token swap) 2`] = ` +Object { + "account": "0xaccount1", + "approvalTxId": undefined, + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 0, + "featureId": undefined, + "hasApprovalTx": false, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "pricingData": Object { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": ".00055", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": Object { + "bridgeId": "lifi", + "bridges": Array [ + "across", + ], + "destAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 42161, + "destTokenAmount": "990654755978612", + "feeData": Object { + "metabridge": Object { + "amount": "8750000000000", + "asset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + "txFee": Object { + "maxFeePerGas": "1395348", + "maxPriorityFeePerGas": "1000001", + }, + }, + "gasIncluded": true, + "gasIncluded7702": false, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": Array [ + Object { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": Object { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": Object { + "srcChain": Object { + "chainId": 42161, + "txHash": "0xevmTxHash", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + exports[`BridgeStatusController submitTx: Solana bridge should handle snap controller errors 1`] = ` Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -3412,6 +3617,8 @@ exports[`BridgeStatusController submitTx: Solana bridge should successfully subm Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -3603,6 +3810,8 @@ exports[`BridgeStatusController submitTx: Solana bridge should throw error when Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -3662,6 +3871,8 @@ exports[`BridgeStatusController submitTx: Solana swap should handle snap control Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -3739,6 +3950,8 @@ exports[`BridgeStatusController submitTx: Solana swap should successfully submit Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -3929,6 +4142,8 @@ exports[`BridgeStatusController submitTx: Solana swap should throw error when ac Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -3941,6 +4156,8 @@ exports[`BridgeStatusController submitTx: Solana swap should throw error when sn Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -4000,6 +4217,8 @@ exports[`BridgeStatusController submitTx: Tron swap with approval should handle Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -4081,6 +4300,8 @@ exports[`BridgeStatusController submitTx: Tron swap with approval should success Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", @@ -4296,6 +4517,8 @@ exports[`BridgeStatusController submitTx: Tron swap with approval should success Array [ Array [ "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, ], Array [ "AccountsController:getAccountByAddress", diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index e8ab1b7553b..6b8cae77460 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -2,25 +2,26 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import type { BridgeControllerMessenger, + QuoteResponse, + QuoteMetadata, TxData, TronTradeData, } from '@metamask/bridge-controller'; import { - type QuoteResponse, - type QuoteMetadata, + ActionTypes, + ChainId, + FeeType, StatusTypes, BridgeController, getNativeAssetForChainId, FeatureId, + getQuotesReceivedProperties, } from '@metamask/bridge-controller'; -import { ChainId } from '@metamask/bridge-controller'; -import { ActionTypes, FeeType } from '@metamask/bridge-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { TransactionType, @@ -39,14 +40,14 @@ import { DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, MAX_ATTEMPTS, } from './constants'; -import type { StatusResponse } from './types'; -import { - type BridgeId, - type StartPollingForBridgeTxStatusArgsSerialized, - type BridgeHistoryItem, - type BridgeStatusControllerState, - type BridgeStatusControllerMessenger, - BridgeClientId, +import { BridgeClientId } from './types'; +import type { + BridgeId, + StartPollingForBridgeTxStatusArgsSerialized, + BridgeHistoryItem, + BridgeStatusControllerState, + BridgeStatusControllerMessenger, + StatusResponse, } from './types'; import * as bridgeStatusUtils from './utils/bridge-status'; import * as transactionUtils from './utils/transaction'; @@ -2559,8 +2560,7 @@ describe('BridgeStatusController', () => { expect(mockMessengerCall.mock.calls).toMatchSnapshot(); }); - it('should handle smart transactions and publish QuotesReceived event if quotes are still loading', async () => { - mockMessengerCall.mockImplementationOnce(jest.fn()); // track QuotesReceived event + it('should handle smart transactions and include quotesReceivedContext', async () => { setupEventTrackingMocks(mockMessengerCall); setupBridgeStxMocks(mockMessengerCall); addTransactionBatchFn.mockResolvedValueOnce({ @@ -2574,8 +2574,7 @@ describe('BridgeStatusController', () => { (quoteWithoutApproval.trade as TxData).from, quoteWithoutApproval, true, - true, - ['low_return'], + getQuotesReceivedProperties(quoteWithoutApproval, ['low_return'], true), ); controller.stopAllPolling(); @@ -2611,12 +2610,66 @@ describe('BridgeStatusController', () => { expect(addTransactionFn).not.toHaveBeenCalled(); }); + it('should throw an error if EVM trade data is not valid', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(undefined); + + const { controller, startPollingForBridgeTxStatusSpy } = + getController(mockMessengerCall); + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + + await expect( + controller.submitTx( + (quoteWithoutApproval.trade as TxData).from, + { + ...quoteWithoutApproval, + trade: (quoteWithoutApproval.trade as TxData).data, + }, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap transaction: trade is not an EVM transaction', + ); + controller.stopAllPolling(); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(addTransactionFn).not.toHaveBeenCalled(); + }); + + it('should throw an error if Solana trade data is not valid', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(undefined); + + const { controller, startPollingForBridgeTxStatusSpy } = + getController(mockMessengerCall); + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + + await expect( + controller.submitTx( + (quoteWithoutApproval.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + srcChainId: ChainId.SOLANA, + }, + }, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap transaction: trade is not a non-EVM transaction', + ); + controller.stopAllPolling(); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(addTransactionFn).not.toHaveBeenCalled(); + }); + it('should reset USDT allowance', async () => { setupEventTrackingMocks(mockMessengerCall); mockIsEthUsdt.mockReturnValueOnce(true); // USDT approval reset - mockMessengerCall.mockReturnValueOnce('1'); setupApprovalMocks(mockMessengerCall); // Approval tx @@ -2629,7 +2682,17 @@ describe('BridgeStatusController', () => { getController(mockMessengerCall); const result = await controller.submitTx( (mockEvmQuoteResponse.trade as TxData).from, - mockEvmQuoteResponse, + { + ...mockEvmQuoteResponse, + resetApproval: { + chainId: 1, + data: '0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000', + from: '0xaccount1', + gasLimit: 21000, + to: '0xtokenContract', + value: '0x0', + }, + }, false, ); controller.stopAllPolling(); @@ -2644,10 +2707,6 @@ describe('BridgeStatusController', () => { it('should handle smart transactions with USDT reset', async () => { setupEventTrackingMocks(mockMessengerCall); - // USDT approval reset - mockIsEthUsdt.mockReturnValueOnce(true); - mockMessengerCall.mockReturnValueOnce('1'); - mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); mockMessengerCall.mockReturnValueOnce('arbitrum'); mockMessengerCall.mockReturnValueOnce({ @@ -2674,7 +2733,17 @@ describe('BridgeStatusController', () => { getController(mockMessengerCall); const result = await controller.submitTx( (mockEvmQuoteResponse.trade as TxData).from, - mockEvmQuoteResponse, + { + ...mockEvmQuoteResponse, + resetApproval: { + chainId: 1, + data: '0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000', + from: '0xaccount1', + gasLimit: 21000, + to: '0xtokenContract', + value: '0x0', + }, + }, true, ); controller.stopAllPolling(); @@ -2688,7 +2757,7 @@ describe('BridgeStatusController', () => { expect(estimateGasFeeFn).toHaveBeenCalledTimes(3); expect(addTransactionFn).not.toHaveBeenCalled(); expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); - expect(mockMessengerCall).toHaveBeenCalledTimes(10); + expect(mockMessengerCall).toHaveBeenCalledTimes(9); }); it('should throw an error if approval tx fails', async () => { @@ -3250,6 +3319,136 @@ describe('BridgeStatusController', () => { expect(mockMessengerCall.mock.calls).toMatchSnapshot(); }); + it('should use quote txFee when gasIncluded is true and STX is off (Max native token swap)', async () => { + setupEventTrackingMocks(mockMessengerCall); + // Setup for single tx path - no gas estimation needed since gasIncluded=true + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + // Skip GasFeeController mock since we use quote's txFee directly + addTransactionFn.mockResolvedValueOnce({ + transactionMeta: mockEvmTxMeta, + result: Promise.resolve('0xevmTxHash'), + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [mockEvmTxMeta], + }); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + + const { controller, startPollingForBridgeTxStatusSpy } = + getController(mockMessengerCall); + + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await controller.submitTx( + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: true, + gasIncluded7702: false, + feeData: { + ...quoteWithoutApproval.quote.feeData, + txFee: { + maxFeePerGas: '1395348', // Decimal string from quote + maxPriorityFeePerGas: '1000001', + }, + }, + }, + } as never, + false, // isStxEnabledOnClient = FALSE (key for this test) + ); + controller.stopAllPolling(); + + // Should use single tx path (addTransactionFn), NOT batch path + expect(addTransactionFn).toHaveBeenCalledTimes(1); + expect(addTransactionBatchFn).not.toHaveBeenCalled(); + + // Should NOT estimate gas (uses quote's txFee instead) + expect(estimateGasFeeFn).not.toHaveBeenCalled(); + + // Verify the tx params have hex-converted gas fees from quote + const txParams = addTransactionFn.mock.calls[0][0]; + expect(txParams.maxFeePerGas).toBe('0x154a94'); // toHex(1395348) + expect(txParams.maxPriorityFeePerGas).toBe('0xf4241'); // toHex(1000001) + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + }); + + it('should estimate gas when gasIncluded is false and STX is off', async () => { + setupEventTrackingMocks(mockMessengerCall); + setupBridgeMocks(); + + const { controller, startPollingForBridgeTxStatusSpy } = + getController(mockMessengerCall); + + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await controller.submitTx( + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: false, + gasIncluded7702: false, + }, + }, + false, // STX off + ); + controller.stopAllPolling(); + + // Should estimate gas since gasIncluded is false + expect(estimateGasFeeFn).toHaveBeenCalledTimes(1); + expect(addTransactionFn).toHaveBeenCalledTimes(1); + expect(addTransactionBatchFn).not.toHaveBeenCalled(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(result).toMatchSnapshot(); + }); + + it('should use batch path when gasIncluded7702 is true regardless of STX setting', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + addTransactionBatchFn.mockResolvedValueOnce({ + batchId: 'batchId1', + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [{ ...mockEvmTxMeta, batchId: 'batchId1' }], + }); + + const { controller, startPollingForBridgeTxStatusSpy } = + getController(mockMessengerCall); + + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await controller.submitTx( + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: true, + gasIncluded7702: true, // 7702 takes precedence → batch path + feeData: { + ...quoteWithoutApproval.quote.feeData, + txFee: { + maxFeePerGas: '1395348', + maxPriorityFeePerGas: '1000001', + }, + }, + }, + } as never, + false, // STX off, but gasIncluded7702 = true forces batch path + ); + controller.stopAllPolling(); + + // Should use batch path because gasIncluded7702 = true + expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); + expect(addTransactionFn).not.toHaveBeenCalled(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(result).toMatchSnapshot(); + }); + it('should handle smart transactions', async () => { setupEventTrackingMocks(mockMessengerCall); mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index b4ee5084919..844da41cbda 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -1,13 +1,11 @@ import type { AccountsControllerState } from '@metamask/accounts-controller'; import type { StateMetadata } from '@metamask/base-controller'; -import type { QuoteWarning } from '@metamask/bridge-controller'; -import { - type QuoteMetadata, - type RequiredEventContextFromClient, - type TxData, - type QuoteResponse, - type Trade, - getQuotesReceivedProperties, +import type { + QuoteMetadata, + RequiredEventContextFromClient, + TxData, + QuoteResponse, + Trade, } from '@metamask/bridge-controller'; import { formatChainIdToHex, @@ -16,24 +14,27 @@ import { UnifiedSwapBridgeEventName, formatChainIdToCaip, isCrossChain, + isEvmTxData, isHardwareWallet, MetricsActionType, isBitcoinTrade, isTronTrade, + AbortReason, } from '@metamask/bridge-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import { toHex } from '@metamask/controller-utils'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; -import type { - TransactionController, - TransactionParams, -} from '@metamask/transaction-controller'; import { TransactionStatus, TransactionType, - type TransactionMeta, } from '@metamask/transaction-controller'; -import { numberToHex, type Hex } from '@metamask/utils'; +import type { + TransactionController, + TransactionMeta, + TransactionParams, +} from '@metamask/transaction-controller'; +import { numberToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { BRIDGE_PROD_API_BASE_URL, @@ -50,7 +51,7 @@ import type { SolanaTransactionMeta, BridgeHistoryItem, } from './types'; -import { type BridgeStatusControllerMessenger } from './types'; +import type { BridgeStatusControllerMessenger } from './types'; import { BridgeClientId } from './types'; import { fetchBridgeTxStatus, @@ -73,7 +74,6 @@ import { getAddTransactionBatchParams, getClientRequest, getStatusRequestParams, - getUSDTAllowanceResetTx, handleApprovalDelay, handleMobileHardwareWalletDelay, handleNonEvmTxResponse, @@ -825,24 +825,24 @@ export class BridgeStatusController extends StaticIntervalPollingController, + srcChainId: QuoteResponse['quote']['srcChainId'], + approval?: TxData, + resetApproval?: TxData, requireApproval?: boolean, ): Promise => { - const { approval } = quoteResponse; - if (approval) { const approveTx = async () => { - await this.#handleUSDTAllowanceReset(quoteResponse); + await this.#handleUSDTAllowanceReset(resetApproval); const approvalTxMeta = await this.#handleEvmTransaction({ transactionType: isBridgeTx ? TransactionType.bridgeApproval : TransactionType.swapApproval, - trade: approval as TxData, + trade: approval, requireApproval, }); - await handleApprovalDelay(quoteResponse); + await handleApprovalDelay(srcChainId); return approvalTxMeta; }; @@ -852,7 +852,7 @@ export class BridgeStatusController extends StaticIntervalPollingController => { const actionId = generateActionId().toString(); @@ -919,6 +924,7 @@ export class BridgeStatusController extends StaticIntervalPollingController, - ) => { - const resetApproval = await getUSDTAllowanceResetTx( - this.messenger, - quoteResponse, - ); + readonly #handleUSDTAllowanceReset = async (resetApproval?: TxData) => { if (resetApproval) { await this.#handleEvmTransaction({ transactionType: TransactionType.bridgeApproval, - trade: resetApproval as TxData, + trade: resetApproval, }); } }; @@ -949,7 +949,20 @@ export class BridgeStatusController extends StaticIntervalPollingController { + const maxGasLimit = toHex(transactionParams.gas ?? 0); + + // If txFee is provided (gasIncluded case), use the quote's gas fees + // Convert to hex since txFee values from the quote are decimal strings + if (txFee) { + return { + maxFeePerGas: toHex(txFee.maxFeePerGas ?? 0), + maxPriorityFeePerGas: toHex(txFee.maxPriorityFeePerGas ?? 0), + gas: maxGasLimit, + }; + } + const { gasFeeEstimates } = this.messenger.call( 'GasFeeController:getState', ); @@ -962,7 +975,6 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata, isStxEnabledOnClient: boolean, - isLoading: boolean = false, - warnings: QuoteWarning[] = [], + quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], ): Promise> => { - // If trade is submitted before all quotes are loaded, publish QuotesReceived event - if (isLoading) { - this.#trackUnifiedSwapBridgeEvent( - UnifiedSwapBridgeEventName.QuotesReceived, - undefined, - getQuotesReceivedProperties(quoteResponse, warnings), - ); - } - this.messenger.call('BridgeController:stopPollingForQuotes'); + this.messenger.call( + 'BridgeController:stopPollingForQuotes', + AbortReason.TransactionSubmitted, + // If trade is submitted before all quotes are loaded, the QuotesReceived event is published + // If the trade has a featureId, it means it was submitted outside of the Unified Swap and Bridge experience, so no QuotesReceived event is published + quoteResponse.featureId ? undefined : quotesReceivedContext, + ); const selectedAccount = this.#getMultichainSelectedAccount(accountAddress); if (!selectedAccount) { @@ -1085,15 +1093,9 @@ export class BridgeStatusController extends StaticIntervalPollingController { try { - return await this.#handleNonEvmTx( - quoteResponse.approval as Trade, - quoteResponse, - selectedAccount, - ); + return quoteResponse.approval && + isTronTrade(quoteResponse.approval) + ? await this.#handleNonEvmTx( + quoteResponse.approval, + quoteResponse, + selectedAccount, + ) + : undefined; } catch (error) { !quoteResponse.featureId && this.#trackUnifiedSwapBridgeEvent( @@ -1129,7 +1134,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { try { + if ( + !( + isTronTrade(quoteResponse.trade) || + isBitcoinTrade(quoteResponse.trade) || + typeof quoteResponse.trade === 'string' + ) + ) { + throw new Error( + 'Failed to submit cross-chain swap transaction: trade is not a non-EVM transaction', + ); + } return await this.#handleNonEvmTx( quoteResponse.trade, quoteResponse, @@ -1182,16 +1198,21 @@ export class BridgeStatusController extends StaticIntervalPollingController { + if (!isEvmTxData(quoteResponse.trade)) { + throw new Error( + 'Failed to submit cross-chain swap transaction: trade is not an EVM transaction', + ); + } if (isStxEnabledOnClient || quoteResponse.quote.gasIncluded7702) { const { tradeMeta, approvalMeta } = await this.#handleEvmTransactionBatch({ isBridgeTx, - resetApproval: (await getUSDTAllowanceResetTx( - this.messenger, - quoteResponse, - )) as TxData, - approval: quoteResponse.approval as TxData, - trade: quoteResponse.trade as TxData, + resetApproval: quoteResponse.resetApproval, + approval: + quoteResponse.approval && isEvmTxData(quoteResponse.approval) + ? quoteResponse.approval + : undefined, + trade: quoteResponse.trade, quoteResponse, requireApproval, }); @@ -1202,7 +1223,11 @@ export class BridgeStatusController extends StaticIntervalPollingController( eventName: T, txMetaId?: string, diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index 051b6abf3da..ce49f81171e 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -285,7 +285,6 @@ type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | HandleSnapRequest | TransactionControllerGetStateAction - | BridgeControllerAction | BridgeControllerAction | BridgeControllerAction | GetGasFeeState diff --git a/packages/bridge-status-controller/src/utils/bridge-status.ts b/packages/bridge-status-controller/src/utils/bridge-status.ts index 3d0f05d9991..fa5d0890a8b 100644 --- a/packages/bridge-status-controller/src/utils/bridge-status.ts +++ b/packages/bridge-status-controller/src/utils/bridge-status.ts @@ -1,4 +1,4 @@ -import { type Quote } from '@metamask/bridge-controller'; +import type { Quote } from '@metamask/bridge-controller'; import { StructError } from '@metamask/superstruct'; import { validateBridgeStatusResponse } from './validators'; diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts index 23b1bae2247..34dc3e90ec8 100644 --- a/packages/bridge-status-controller/src/utils/metrics.ts +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -1,19 +1,10 @@ import type { AccountsControllerState } from '@metamask/accounts-controller'; -import type { - QuoteResponse, - QuoteMetadata, - QuoteFetchData, -} from '@metamask/bridge-controller'; import { - type TxStatusData, StatusTypes, formatChainIdToHex, isEthUsdt, - type RequestParams, formatChainIdToCaip, - type TradeData, formatProviderLabel, - type RequestMetadata, isCustomSlippage, getSwapType, isHardwareWallet, @@ -21,11 +12,20 @@ import { MetricsActionType, MetricsSwapType, } from '@metamask/bridge-controller'; +import type { + QuoteFetchData, + QuoteMetadata, + QuoteResponse, + TxStatusData, + RequestParams, + TradeData, + RequestMetadata, +} from '@metamask/bridge-controller'; import { TransactionStatus, TransactionType, - type TransactionMeta, } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { CaipAssetType } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; diff --git a/packages/bridge-status-controller/src/utils/swap-received-amount.ts b/packages/bridge-status-controller/src/utils/swap-received-amount.ts index 742955ee0a0..f8553e77e64 100644 --- a/packages/bridge-status-controller/src/utils/swap-received-amount.ts +++ b/packages/bridge-status-controller/src/utils/swap-received-amount.ts @@ -1,6 +1,6 @@ import type { TokenAmountValues } from '@metamask/bridge-controller'; import { isNativeAddress } from '@metamask/bridge-controller'; -import { type TransactionMeta } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import { BigNumber } from 'bignumber.js'; import type { BridgeHistoryItem } from '../types'; @@ -33,7 +33,7 @@ const getReceivedERC20Amount = ( txMeta: TransactionMeta, ) => { const { txReceipt } = txMeta; - if (!txReceipt || !txReceipt.logs || txReceipt.status === '0x0') { + if (!txReceipt?.logs || txReceipt.status === '0x0') { return null; } const { account: accountAddress, quote } = historyItem; @@ -42,15 +42,14 @@ const getReceivedERC20Amount = ( '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; const tokenTransferLog = txReceipt.logs.find((txReceiptLog) => { - const isTokenTransfer = - txReceiptLog.topics && - txReceiptLog.topics[0]?.startsWith(TOKEN_TRANSFER_LOG_TOPIC_HASH); + const isTokenTransfer = txReceiptLog.topics?.[0]?.startsWith( + TOKEN_TRANSFER_LOG_TOPIC_HASH, + ); const isTransferFromGivenToken = txReceiptLog.address?.toLowerCase() === quote.destAsset.address?.toLowerCase(); const isTransferFromGivenAddress = - txReceiptLog.topics && - txReceiptLog.topics[2] && + txReceiptLog.topics?.[2] && (txReceiptLog.topics[2] === accountAddress || txReceiptLog.topics[2].match(accountAddress?.slice(2))); diff --git a/packages/bridge-status-controller/src/utils/transaction.test.ts b/packages/bridge-status-controller/src/utils/transaction.test.ts index dc026e8693e..eb6e075e1ea 100644 --- a/packages/bridge-status-controller/src/utils/transaction.test.ts +++ b/packages/bridge-status-controller/src/utils/transaction.test.ts @@ -3,9 +3,11 @@ import { FeeType, formatChainIdToCaip, formatChainIdToHex, - type QuoteMetadata, - type QuoteResponse, - type TxData, +} from '@metamask/bridge-controller'; +import type { + QuoteMetadata, + QuoteResponse, + TxData, } from '@metamask/bridge-controller'; import { TransactionStatus, @@ -1271,7 +1273,9 @@ describe('Bridge Status Controller Transaction Utils', () => { } as unknown as QuoteResponse; // Create a promise that will resolve after the delay - const delayPromise = handleApprovalDelay(mockQuoteResponse); + const delayPromise = handleApprovalDelay( + mockQuoteResponse.quote.srcChainId, + ); // Verify that the timer was set with the correct delay expect(jest.getTimerCount()).toBe(1); @@ -1309,7 +1313,9 @@ describe('Bridge Status Controller Transaction Utils', () => { } as unknown as QuoteResponse; // Create a promise that will resolve after the delay - const delayPromise = handleApprovalDelay(mockQuoteResponse); + const delayPromise = handleApprovalDelay( + mockQuoteResponse.quote.srcChainId, + ); // Verify that the timer was set with the correct delay expect(jest.getTimerCount()).toBe(1); @@ -1347,7 +1353,9 @@ describe('Bridge Status Controller Transaction Utils', () => { } as unknown as QuoteResponse; // Create a promise that will resolve after the delay - const delayPromise = handleApprovalDelay(mockQuoteResponse); + const delayPromise = handleApprovalDelay( + mockQuoteResponse.quote.srcChainId, + ); // Verify that no timer was set expect(jest.getTimerCount()).toBe(0); diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index 06d619800b3..63248853447 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -1,32 +1,29 @@ import type { AccountsControllerState } from '@metamask/accounts-controller'; -import type { TxData } from '@metamask/bridge-controller'; import { ChainId, + extractTradeData, + isTronTrade, formatChainIdToCaip, formatChainIdToHex, - getEthUsdtResetData, isCrossChain, - isEthUsdt, - type QuoteMetadata, - type QuoteResponse, } from '@metamask/bridge-controller'; -import { - extractTradeData, - isTronTrade, - type Trade, +import type { + QuoteMetadata, + QuoteResponse, + Trade, + TxData, } from '@metamask/bridge-controller'; import { toHex } from '@metamask/controller-utils'; -import type { - BatchTransactionParams, - TransactionController, -} from '@metamask/transaction-controller'; import { TransactionStatus, TransactionType, - type TransactionMeta, +} from '@metamask/transaction-controller'; +import type { + BatchTransactionParams, + TransactionController, + TransactionMeta, } from '@metamask/transaction-controller'; import { createProjectLogger } from '@metamask/utils'; -import { BigNumber } from 'bignumber.js'; import { v4 as uuid } from 'uuid'; import { calculateGasFees } from './gas'; @@ -40,31 +37,6 @@ import type { export const generateActionId = () => (Date.now() + Math.random()).toString(); -export const getUSDTAllowanceResetTx = async ( - messenger: BridgeStatusControllerMessenger, - quoteResponse: QuoteResponse & Partial, -) => { - const hexChainId = formatChainIdToHex(quoteResponse.quote.srcChainId); - if ( - quoteResponse.approval && - isEthUsdt(hexChainId, quoteResponse.quote.srcAsset.address) - ) { - const allowance = new BigNumber( - await messenger.call( - 'BridgeController:getBridgeERC20Allowance', - quoteResponse.quote.srcAsset.address, - hexChainId, - ), - ); - const shouldResetApproval = - allowance.lt(quoteResponse.sentAmount?.amount ?? '0') && allowance.gt(0); - if (shouldResetApproval) { - return { ...quoteResponse.approval, data: getEthUsdtResetData() }; - } - } - return undefined; -}; - export const getStatusRequestParams = (quoteResponse: QuoteResponse) => { return { bridgeId: quoteResponse.quote.bridgeId, @@ -196,8 +168,10 @@ export const handleNonEvmTxResponse = ( }; }; -export const handleApprovalDelay = async (quoteResponse: QuoteResponse) => { - if ([ChainId.LINEA, ChainId.BASE].includes(quoteResponse.quote.srcChainId)) { +export const handleApprovalDelay = async ( + srcChainId: QuoteResponse['quote']['srcChainId'], +) => { + if ([ChainId.LINEA, ChainId.BASE].includes(srcChainId)) { const debugLog = createProjectLogger('bridge'); debugLog( 'Delaying submitting bridge tx to make Linea and Base confirmation more likely', diff --git a/packages/build-utils/src/transforms/remove-fenced-code.ts b/packages/build-utils/src/transforms/remove-fenced-code.ts index b872989a324..f8e5ac3a632 100644 --- a/packages/build-utils/src/transforms/remove-fenced-code.ts +++ b/packages/build-utils/src/transforms/remove-fenced-code.ts @@ -262,8 +262,7 @@ export function removeFencedCode( // Forbid empty fences const { line: previousLine, indices: previousIndices } = // We're only in this case if i > 0, so this will always be defined. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - parsedDirectives[i - 1]!; + parsedDirectives[i - 1]; if (fileContent.substring(previousIndices[1], indices[0]).trim() === '') { throw new Error( `Empty fence found in file "${filePath}":\n${previousLine}\n${line}\n`, @@ -320,7 +319,7 @@ export function multiSplice( throw new Error('Expected array of non-negative integers.'); } - const retainedSubstrings = []; + const retainedSubstrings: string[] = []; // Get the first part to be included // The substring() call returns an empty string if splicingIndices[0] is 0, @@ -340,8 +339,7 @@ export function multiSplice( retainedSubstrings.push( // splicingIndices[i] refers to an element between the first and last // elements of the array, and will always be defined. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - toSplice.substring(splicingIndices[i]!, splicingIndices[i + 1]), + toSplice.substring(splicingIndices[i], splicingIndices[i + 1]), ); } } @@ -349,8 +347,7 @@ export function multiSplice( // Get the last part to be included retainedSubstrings.push( // The last element of a non-empty array will always be defined. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - toSplice.substring(splicingIndices[splicingIndices.length - 1]!), + toSplice.substring(splicingIndices[splicingIndices.length - 1]), ); return retainedSubstrings.join(''); } diff --git a/packages/chain-agnostic-permission/CHANGELOG.md b/packages/chain-agnostic-permission/CHANGELOG.md index 9eab2e00bfd..60a02d765a3 100644 --- a/packages/chain-agnostic-permission/CHANGELOG.md +++ b/packages/chain-agnostic-permission/CHANGELOG.md @@ -7,9 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] + +### Added + +- Add `TronAccountChangedNotifications` property in `KnownSessionProperties` enum ([#7304](https://github.com/MetaMask/core/pull/7304)) + ### Changed -- Bump `@metamask/network-controller` from `^25.0.0` to `^26.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/network-controller` from `^26.0.0` to `^27.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202), [#7258](https://github.com/MetaMask/core/pull/7258)) - Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) - Bump `@metamask/permission-controller` from `^12.1.0` to `^12.1.1` ([#6988](https://github.com/MetaMask/core/pull/6988), [#7202](https://github.com/MetaMask/core/pull/7202)) @@ -161,7 +167,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.2.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.3.0...HEAD +[1.3.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.2.2...@metamask/chain-agnostic-permission@1.3.0 [1.2.2]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.2.1...@metamask/chain-agnostic-permission@1.2.2 [1.2.1]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.2.0...@metamask/chain-agnostic-permission@1.2.1 [1.2.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.1.1...@metamask/chain-agnostic-permission@1.2.0 diff --git a/packages/chain-agnostic-permission/package.json b/packages/chain-agnostic-permission/package.json index 98c317a45f5..48f5dcde00b 100644 --- a/packages/chain-agnostic-permission/package.json +++ b/packages/chain-agnostic-permission/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/chain-agnostic-permission", - "version": "1.2.2", + "version": "1.3.0", "description": "Defines a CAIP-25 based endowment permission and helpers for interfacing with it", "keywords": [ "MetaMask", @@ -50,7 +50,7 @@ "dependencies": { "@metamask/api-specs": "^0.14.0", "@metamask/controller-utils": "^11.16.0", - "@metamask/network-controller": "^26.0.0", + "@metamask/network-controller": "^27.0.0", "@metamask/permission-controller": "^12.1.1", "@metamask/rpc-errors": "^7.0.2", "@metamask/utils": "^11.8.1", diff --git a/packages/chain-agnostic-permission/src/caip25Permission.test.ts b/packages/chain-agnostic-permission/src/caip25Permission.test.ts index 67bb525064e..9cb20bc676d 100644 --- a/packages/chain-agnostic-permission/src/caip25Permission.test.ts +++ b/packages/chain-agnostic-permission/src/caip25Permission.test.ts @@ -1,10 +1,12 @@ import { CaveatMutatorOperation, PermissionType, - type SubjectPermissions, - type ExtractPermission, - type PermissionSpecificationConstraint, - type CaveatSpecificationConstraint, +} from '@metamask/permission-controller'; +import type { + SubjectPermissions, + ExtractPermission, + PermissionSpecificationConstraint, + CaveatSpecificationConstraint, } from '@metamask/permission-controller'; import { pick } from 'lodash'; diff --git a/packages/chain-agnostic-permission/src/caip25Permission.ts b/packages/chain-agnostic-permission/src/caip25Permission.ts index f625b6435c7..d8e986e7971 100644 --- a/packages/chain-agnostic-permission/src/caip25Permission.ts +++ b/packages/chain-agnostic-permission/src/caip25Permission.ts @@ -11,14 +11,18 @@ import { CaveatMutatorOperation, PermissionType, } from '@metamask/permission-controller'; -import type { CaipAccountId, CaipChainId, Json } from '@metamask/utils'; import { hasProperty, KnownCaipNamespace, parseCaipAccountId, isObject, - type Hex, - type NonEmptyArray, +} from '@metamask/utils'; +import type { + CaipAccountId, + CaipChainId, + Json, + Hex, + NonEmptyArray, } from '@metamask/utils'; import { cloneDeep, isEqual, pick } from 'lodash'; @@ -38,11 +42,11 @@ import { isSupportedSessionProperty, } from './scope/supported'; import { mergeInternalScopes } from './scope/transform'; -import { - parseScopeString, - type ExternalScopeString, - type InternalScopeObject, - type InternalScopesObject, +import { parseScopeString } from './scope/types'; +import type { + ExternalScopeString, + InternalScopeObject, + InternalScopesObject, } from './scope/types'; /** diff --git a/packages/chain-agnostic-permission/src/operators/caip-permission-operator-accounts.ts b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-accounts.ts index dbd085b88b3..71278aca353 100644 --- a/packages/chain-agnostic-permission/src/operators/caip-permission-operator-accounts.ts +++ b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-accounts.ts @@ -2,14 +2,16 @@ import { isEqualCaseInsensitive } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { assertIsStrictHexString, - type CaipAccountAddress, - type CaipAccountId, - type CaipNamespace, - type CaipReference, - type Hex, KnownCaipNamespace, parseCaipAccountId, } from '@metamask/utils'; +import type { + CaipAccountAddress, + CaipAccountId, + CaipNamespace, + CaipReference, + Hex, +} from '@metamask/utils'; import type { Caip25CaveatValue } from '../caip25Permission'; import { KnownWalletScopeString } from '../scope/constants'; @@ -244,7 +246,7 @@ const setNonSCACaipAccountIdsInScopesObject = ( const updatedScopesObject: InternalScopesObject = {}; for (const [scopeString, scopeObject] of Object.entries(scopesObject)) { - const { namespace, reference } = parseScopeString(scopeString as string); + const { namespace, reference } = parseScopeString(scopeString); let caipAccounts: CaipAccountId[] = []; @@ -252,7 +254,7 @@ const setNonSCACaipAccountIdsInScopesObject = ( const addressSet = accountsByNamespace.get(namespace); if (addressSet) { caipAccounts = Array.from(addressSet).map( - (address) => `${namespace}:${reference}:${address}` as CaipAccountId, + (address) => `${namespace}:${reference}:${address}` as const, ); } } diff --git a/packages/chain-agnostic-permission/src/operators/caip-permission-operator-permittedChains.ts b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-permittedChains.ts index ae19b0568f6..c8feac45825 100644 --- a/packages/chain-agnostic-permission/src/operators/caip-permission-operator-permittedChains.ts +++ b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-permittedChains.ts @@ -2,7 +2,8 @@ import { toHex } from '@metamask/controller-utils'; import type { Hex, CaipChainId, CaipNamespace } from '@metamask/utils'; import { hexToBigInt, KnownCaipNamespace } from '@metamask/utils'; -import { Caip25CaveatType, type Caip25CaveatValue } from '../caip25Permission'; +import { Caip25CaveatType } from '../caip25Permission'; +import type { Caip25CaveatValue } from '../caip25Permission'; import { getUniqueArrayItems } from '../scope/transform'; import type { InternalScopesObject, InternalScopeString } from '../scope/types'; import { isWalletScope, parseScopeString } from '../scope/types'; diff --git a/packages/chain-agnostic-permission/src/operators/caip-permission-operator-session-scopes.ts b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-session-scopes.ts index 0da325cb537..16fb6983cf1 100644 --- a/packages/chain-agnostic-permission/src/operators/caip-permission-operator-session-scopes.ts +++ b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-session-scopes.ts @@ -1,9 +1,5 @@ -import { - type CaipAccountId, - type CaipChainId, - isCaipChainId, - KnownCaipNamespace, -} from '@metamask/utils'; +import { isCaipChainId, KnownCaipNamespace } from '@metamask/utils'; +import type { CaipAccountId, CaipChainId } from '@metamask/utils'; import type { Caip25CaveatValue } from '../caip25Permission'; import { diff --git a/packages/chain-agnostic-permission/src/scope/assert.ts b/packages/chain-agnostic-permission/src/scope/assert.ts index 38508aaa8cd..01eda87eb93 100644 --- a/packages/chain-agnostic-permission/src/scope/assert.ts +++ b/packages/chain-agnostic-permission/src/scope/assert.ts @@ -1,13 +1,12 @@ import { - type CaipChainId, hasProperty, isCaipAccountId, isCaipChainId, isCaipNamespace, isCaipReference, KnownCaipNamespace, - type Hex, } from '@metamask/utils'; +import type { CaipChainId, Hex } from '@metamask/utils'; import { Caip25Errors } from './errors'; import { diff --git a/packages/chain-agnostic-permission/src/scope/constants.test.ts b/packages/chain-agnostic-permission/src/scope/constants.test.ts index 5f61c82e19c..5958be97902 100644 --- a/packages/chain-agnostic-permission/src/scope/constants.test.ts +++ b/packages/chain-agnostic-permission/src/scope/constants.test.ts @@ -65,6 +65,7 @@ describe('KnownSessionProperties', () => { expect(KnownSessionProperties).toMatchInlineSnapshot(` Object { "SolanaAccountChangedNotifications": "solana_accountChanged_notifications", + "TronAccountChangedNotifications": "tron_accountChanged_notifications", } `); }); diff --git a/packages/chain-agnostic-permission/src/scope/constants.ts b/packages/chain-agnostic-permission/src/scope/constants.ts index eeb16680a67..ba8e0e526b4 100644 --- a/packages/chain-agnostic-permission/src/scope/constants.ts +++ b/packages/chain-agnostic-permission/src/scope/constants.ts @@ -99,6 +99,7 @@ export const KnownNotifications: Record = */ export enum KnownSessionProperties { SolanaAccountChangedNotifications = 'solana_accountChanged_notifications', + TronAccountChangedNotifications = 'tron_accountChanged_notifications', } /** diff --git a/packages/claims-controller/src/ClaimsService.ts b/packages/claims-controller/src/ClaimsService.ts index c643a7ee704..f73d6d1c5e5 100644 --- a/packages/claims-controller/src/ClaimsService.ts +++ b/packages/claims-controller/src/ClaimsService.ts @@ -5,9 +5,9 @@ import type { Hex } from '@metamask/utils'; import { CLAIMS_API_URL_MAP, ClaimsServiceErrorMessages, - type Env, SERVICE_NAME, } from './constants'; +import type { Env } from './constants'; import type { Claim, ClaimsConfigurationsResponse, diff --git a/packages/claims-controller/tests/mocks/messenger.ts b/packages/claims-controller/tests/mocks/messenger.ts index 3acf3595700..4ccae3ac5a5 100644 --- a/packages/claims-controller/tests/mocks/messenger.ts +++ b/packages/claims-controller/tests/mocks/messenger.ts @@ -1,9 +1,8 @@ -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { diff --git a/packages/composable-controller/src/ComposableController.test.ts b/packages/composable-controller/src/ComposableController.test.ts index ef86c1f40d4..adc79ae8381 100644 --- a/packages/composable-controller/src/ComposableController.test.ts +++ b/packages/composable-controller/src/ComposableController.test.ts @@ -1,17 +1,18 @@ import { BaseController, - type ControllerStateChangeEvent, - type ControllerGetStateAction, - type StateConstraint, deriveStateFromMetadata, } from '@metamask/base-controller'; +import type { + ControllerStateChangeEvent, + ControllerGetStateAction, + StateConstraint, +} from '@metamask/base-controller'; import { JsonRpcEngine } from '@metamask/json-rpc-engine'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { Patch } from 'immer'; import * as sinon from 'sinon'; diff --git a/packages/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md index 046fbd381e3..94c4b187905 100644 --- a/packages/controller-utils/CHANGELOG.md +++ b/packages/controller-utils/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `monad-testnet` to `InfuraNetworkType` ([#7067](https://github.com/MetaMask/core/pull/7067)) + ## [11.16.0] ### Added diff --git a/packages/controller-utils/src/types.ts b/packages/controller-utils/src/types.ts index 649c36e9e60..71f905d51c6 100644 --- a/packages/controller-utils/src/types.ts +++ b/packages/controller-utils/src/types.ts @@ -14,6 +14,7 @@ export const InfuraNetworkType = { 'optimism-mainnet': 'optimism-mainnet', 'polygon-mainnet': 'polygon-mainnet', 'sei-mainnet': 'sei-mainnet', + 'monad-testnet': 'monad-testnet', } as const; export type InfuraNetworkType = @@ -24,6 +25,9 @@ export type InfuraNetworkType = */ export const CustomNetworkType = { 'megaeth-testnet': 'megaeth-testnet', + /** + * @deprecated `monad-testnet` is supported on InfuraNetworkType instead. + */ 'monad-testnet': 'monad-testnet', } as const; export type CustomNetworkType = diff --git a/packages/controller-utils/src/util.ts b/packages/controller-utils/src/util.ts index 65164aa1756..5a6a9be9097 100644 --- a/packages/controller-utils/src/util.ts +++ b/packages/controller-utils/src/util.ts @@ -297,8 +297,6 @@ function toChecksumHexAddressUnmemoized(address: string): string; */ function toChecksumHexAddressUnmemoized(address: T): T; -// Tools only see JSDocs for overloads and ignore them for the implementation. -// eslint-disable-next-line jsdoc/require-jsdoc function toChecksumHexAddressUnmemoized(address: unknown) { if (typeof address !== 'string') { // Mimic behavior of `addHexPrefix` from `ethereumjs-util` (which this @@ -344,8 +342,6 @@ export const toChecksumHexAddress: { (address: T): T; } = memoize(toChecksumHexAddressUnmemoized); -// JSDoc is only used for memoized version of this function that is exported -// eslint-disable-next-line jsdoc/require-jsdoc function isValidHexAddressUnmemoized( possibleAddress: string, { allowNonPrefixed = true } = {}, diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index 8cb019cf086..bb24b8e6207 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/accounts-controller` (^35.0.0) + - `@metamask/keyring-controller` (^25.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [5.0.0] ### Changed diff --git a/packages/core-backend/package.json b/packages/core-backend/package.json index dfcc7b6ccda..40c9d67c4ca 100644 --- a/packages/core-backend/package.json +++ b/packages/core-backend/package.json @@ -48,16 +48,16 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/accounts-controller": "^35.0.0", "@metamask/controller-utils": "^11.16.0", + "@metamask/keyring-controller": "^25.0.0", "@metamask/messenger": "^0.3.0", "@metamask/profile-sync-controller": "^27.0.0", "@metamask/utils": "^11.8.1", "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/accounts-controller": "^35.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^25.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -68,10 +68,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/accounts-controller": "^35.0.0", - "@metamask/keyring-controller": "^25.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/core-backend/src/AccountActivityService.test.ts b/packages/core-backend/src/AccountActivityService.test.ts index 2472e133ab1..d8d25cdee10 100644 --- a/packages/core-backend/src/AccountActivityService.test.ts +++ b/packages/core-backend/src/AccountActivityService.test.ts @@ -1,17 +1,16 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { Hex } from '@metamask/utils'; -import { - AccountActivityService, - type AccountActivityServiceMessenger, - type SubscriptionOptions, +import { AccountActivityService } from './AccountActivityService'; +import type { + AccountActivityServiceMessenger, + SubscriptionOptions, } from './AccountActivityService'; import type { ServerNotificationMessage } from './BackendWebSocketService'; import { WebSocketState } from './BackendWebSocketService'; diff --git a/packages/core-backend/src/AccountActivityService.ts b/packages/core-backend/src/AccountActivityService.ts index 506245d6f93..a8b61a427ef 100644 --- a/packages/core-backend/src/AccountActivityService.ts +++ b/packages/core-backend/src/AccountActivityService.ts @@ -510,7 +510,7 @@ export class AccountActivityService { 'AccountsController:getSelectedAccount', ); - if (!selectedAccount || !selectedAccount.address) { + if (!selectedAccount?.address) { return; } diff --git a/packages/core-backend/src/BackendWebSocketService.test.ts b/packages/core-backend/src/BackendWebSocketService.test.ts index 94332d05f88..5b1440548bc 100644 --- a/packages/core-backend/src/BackendWebSocketService.test.ts +++ b/packages/core-backend/src/BackendWebSocketService.test.ts @@ -1,17 +1,18 @@ -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { BackendWebSocketService, getCloseReason, WebSocketState, - type BackendWebSocketServiceOptions, - type BackendWebSocketServiceMessenger, +} from './BackendWebSocketService'; +import type { + BackendWebSocketServiceOptions, + BackendWebSocketServiceMessenger, } from './BackendWebSocketService'; import { flushPromises } from '../../../tests/helpers'; diff --git a/packages/core-backend/src/BackendWebSocketService.ts b/packages/core-backend/src/BackendWebSocketService.ts index 0029c30f3dc..4edd000ec0d 100644 --- a/packages/core-backend/src/BackendWebSocketService.ts +++ b/packages/core-backend/src/BackendWebSocketService.ts @@ -530,8 +530,7 @@ export class BackendWebSocketService { log('Forcing WebSocket reconnection to clean up subscription state'); // This ensures ws.onclose will schedule a reconnect (not treat it as manual disconnect) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.#ws!.close(FORCE_RECONNECT_CODE, FORCE_RECONNECT_REASON); + this.#ws.close(FORCE_RECONNECT_CODE, FORCE_RECONNECT_REASON); } /** @@ -1130,7 +1129,7 @@ export class BackendWebSocketService { // Trigger channel callbacks for any message with a channel property if (this.#isChannelMessage(message)) { - const channelMsg = message as ServerNotificationMessage; + const channelMsg = message; this.#handleChannelMessage(channelMsg); } } diff --git a/packages/delegation-controller/CHANGELOG.md b/packages/delegation-controller/CHANGELOG.md index cc90a09474d..8a76879164a 100644 --- a/packages/delegation-controller/CHANGELOG.md +++ b/packages/delegation-controller/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/accounts-controller` (^35.0.0) + - `@metamask/keyring-controller` (^25.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [2.0.0] ### Changed diff --git a/packages/delegation-controller/package.json b/packages/delegation-controller/package.json index 6665b3bc5b3..f0d85b6dfd2 100644 --- a/packages/delegation-controller/package.json +++ b/packages/delegation-controller/package.json @@ -48,14 +48,14 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/accounts-controller": "^35.0.0", "@metamask/base-controller": "^9.0.0", + "@metamask/keyring-controller": "^25.0.0", "@metamask/messenger": "^0.3.0", "@metamask/utils": "^11.8.1" }, "devDependencies": { - "@metamask/accounts-controller": "^35.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^25.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -65,10 +65,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/accounts-controller": "^35.0.0", - "@metamask/keyring-controller": "^25.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/delegation-controller/src/DelegationController.test.ts b/packages/delegation-controller/src/DelegationController.test.ts index 9c90c5cb3fd..c3b808da425 100644 --- a/packages/delegation-controller/src/DelegationController.test.ts +++ b/packages/delegation-controller/src/DelegationController.test.ts @@ -1,11 +1,10 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import { SignTypedDataVersion } from '@metamask/keyring-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { hexToNumber } from '@metamask/utils'; @@ -413,12 +412,12 @@ describe(`${controllerName}`, () => { const { controller } = createController(); const parentDelegation = { ...DELEGATION_MOCK, - authority: ROOT_AUTHORITY as Hex, + authority: ROOT_AUTHORITY, }; const parentHash = hashDelegationMock(parentDelegation); const childDelegation = { ...DELEGATION_MOCK, - authority: parentHash as Hex, + authority: parentHash, }; const childHash = hashDelegationMock(childDelegation); const parentEntry = { @@ -473,7 +472,7 @@ describe(`${controllerName}`, () => { const { controller } = createController(); const rootDelegation = { ...DELEGATION_MOCK, - authority: ROOT_AUTHORITY as Hex, + authority: ROOT_AUTHORITY, }; const rootEntry = { ...DELEGATION_ENTRY_MOCK, @@ -481,7 +480,7 @@ describe(`${controllerName}`, () => { }; controller.store({ entry: rootEntry }); - const result = controller.chain(ROOT_AUTHORITY as Hex); + const result = controller.chain(ROOT_AUTHORITY); expect(result).toBeNull(); }); @@ -504,12 +503,12 @@ describe(`${controllerName}`, () => { const { controller } = createController(); const parentDelegation = { ...DELEGATION_MOCK, - authority: ROOT_AUTHORITY as Hex, + authority: ROOT_AUTHORITY, }; const parentHash = hashDelegationMock(parentDelegation); const childDelegation = { ...DELEGATION_MOCK, - authority: parentHash as Hex, + authority: parentHash, }; const childHash = hashDelegationMock(childDelegation); const parentEntry = { @@ -534,7 +533,7 @@ describe(`${controllerName}`, () => { const { controller } = createController(); const parentDelegation = { ...DELEGATION_MOCK, - authority: ROOT_AUTHORITY as Hex, + authority: ROOT_AUTHORITY, }; const parentHash = hashDelegationMock(parentDelegation); const child1Delegation = { @@ -585,7 +584,7 @@ describe(`${controllerName}`, () => { // -> child2 -> grandchild2 const rootDelegation = { ...DELEGATION_MOCK, - authority: ROOT_AUTHORITY as Hex, + authority: ROOT_AUTHORITY, salt: '0x0' as Hex, }; const rootHash = hashDelegationMock(rootDelegation); diff --git a/packages/earn-controller/CHANGELOG.md b/packages/earn-controller/CHANGELOG.md index e2652af9067..2b9e4da17f7 100644 --- a/packages/earn-controller/CHANGELOG.md +++ b/packages/earn-controller/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7258](https://github.com/MetaMask/core/pull/7258)) + - The dependencies moved are: + - `@metamask/account-tree-controller` (^4.0.0) + - `@metamask/network-controller` (^27.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [11.0.0] ### Changed diff --git a/packages/earn-controller/package.json b/packages/earn-controller/package.json index 0b31156415b..c3250db3a00 100644 --- a/packages/earn-controller/package.json +++ b/packages/earn-controller/package.json @@ -50,18 +50,18 @@ "dependencies": { "@ethersproject/bignumber": "^5.7.0", "@ethersproject/providers": "^5.7.0", + "@metamask/account-tree-controller": "^4.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.16.0", "@metamask/keyring-api": "^21.0.0", "@metamask/messenger": "^0.3.0", + "@metamask/network-controller": "^27.0.0", "@metamask/stake-sdk": "^3.2.1", "reselect": "^5.1.1" }, "devDependencies": { - "@metamask/account-tree-controller": "^4.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^26.0.0", - "@metamask/transaction-controller": "^62.0.0", + "@metamask/transaction-controller": "^62.5.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -71,10 +71,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/account-tree-controller": "^4.0.0", - "@metamask/network-controller": "^26.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/earn-controller/src/EarnController.test.ts b/packages/earn-controller/src/EarnController.test.ts index d8d46ced428..3fab40a7b0a 100644 --- a/packages/earn-controller/src/EarnController.test.ts +++ b/packages/earn-controller/src/EarnController.test.ts @@ -1,30 +1,33 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import { toHex } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { getDefaultNetworkControllerState } from '@metamask/network-controller'; import { EarnSdk, EarnApiService, - type PooledStakingApiService, - type LendingApiService, - type LendingMarket, EarnEnvironments, ChainId, } from '@metamask/stake-sdk'; +import type { + PooledStakingApiService, + LendingApiService, + LendingMarket, +} from '@metamask/stake-sdk'; import { EarnController, - type EarnControllerState, - type EarnControllerMessenger, DEFAULT_POOLED_STAKING_CHAIN_STATE, } from './EarnController'; +import type { + EarnControllerState, + EarnControllerMessenger, +} from './EarnController'; import type { TransactionMeta } from '../../transaction-controller/src'; import { TransactionStatus, diff --git a/packages/earn-controller/src/EarnController.ts b/packages/earn-controller/src/EarnController.ts index 193d60d07ef..d0efc502c0d 100644 --- a/packages/earn-controller/src/EarnController.ts +++ b/packages/earn-controller/src/EarnController.ts @@ -22,24 +22,26 @@ import { EarnSdk, EarnApiService, isSupportedLendingChain, - type LendingMarket, - type PooledStake, - type EarnSdkConfig, - type VaultData, - type VaultDailyApy, - type VaultApyAverages, - type LendingPosition, - type GasLimitParams, - type HistoricLendingMarketApys, EarnEnvironments, ChainId, isSupportedPooledStakingChain, } from '@metamask/stake-sdk'; -import { - type TransactionController, - TransactionType, - type TransactionControllerTransactionConfirmedEvent, - type TransactionMeta, +import type { + LendingMarket, + PooledStake, + EarnSdkConfig, + VaultData, + VaultDailyApy, + VaultApyAverages, + LendingPosition, + GasLimitParams, + HistoricLendingMarketApys, +} from '@metamask/stake-sdk'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { + TransactionController, + TransactionControllerTransactionConfirmedEvent, + TransactionMeta, } from '@metamask/transaction-controller'; import type { diff --git a/packages/earn-controller/src/selectors.ts b/packages/earn-controller/src/selectors.ts index af725541990..5e61f9964c0 100644 --- a/packages/earn-controller/src/selectors.ts +++ b/packages/earn-controller/src/selectors.ts @@ -21,13 +21,13 @@ export const selectLendingMarketsForChainId = (chainId: number) => export const selectLendingMarketsByProtocolAndId = createSelector( selectLendingMarkets, (markets) => { - return markets.reduce( + return markets.reduce>>( (acc, market) => { acc[market.protocol] = acc[market.protocol] || {}; acc[market.protocol][market.id] = market; return acc; }, - {} as Record>, + {}, ); }, ); @@ -44,14 +44,11 @@ export const selectLendingMarketForProtocolAndId = ( export const selectLendingMarketsByChainId = createSelector( selectLendingMarkets, (markets) => { - return markets.reduce( - (acc, market) => { - acc[market.chainId] = acc[market.chainId] || []; - acc[market.chainId].push(market); - return acc; - }, - {} as Record, - ); + return markets.reduce>((acc, market) => { + acc[market.chainId] = acc[market.chainId] || []; + acc[market.chainId].push(market); + return acc; + }, {}); }, ); @@ -72,35 +69,30 @@ export const selectLendingPositionsWithMarket = createSelector( export const selectLendingPositionsByChainId = createSelector( selectLendingPositionsWithMarket, (positionsWithMarket) => { - return positionsWithMarket.reduce( - (acc, position) => { - const chainId = position.market?.chainId; - if (chainId) { - acc[chainId] = acc[chainId] || []; - acc[chainId].push(position); - } - return acc; - }, - {} as Record, - ); + return positionsWithMarket.reduce< + Record + >((acc, position) => { + const chainId = position.market?.chainId; + if (chainId) { + acc[chainId] = acc[chainId] || []; + acc[chainId].push(position); + } + return acc; + }, {}); }, ); export const selectLendingPositionsByProtocolChainIdMarketId = createSelector( selectLendingPositionsWithMarket, (positionsWithMarket) => - positionsWithMarket.reduce( - (acc, position) => { - acc[position.protocol] ??= {}; - acc[position.protocol][position.chainId] ??= {}; - acc[position.protocol][position.chainId][position.marketId] = position; - return acc; - }, - {} as Record< - string, - Record> - >, - ), + positionsWithMarket.reduce< + Record>> + >((acc, position) => { + acc[position.protocol] ??= {}; + acc[position.protocol][position.chainId] ??= {}; + acc[position.protocol][position.chainId][position.marketId] = position; + return acc; + }, {}), ); export const selectLendingMarketsWithPosition = createSelector( @@ -122,46 +114,43 @@ export const selectLendingMarketsWithPosition = createSelector( export const selectLendingMarketsByTokenAddress = createSelector( selectLendingMarketsWithPosition, (marketsWithPosition) => { - return marketsWithPosition.reduce( - (acc, market) => { - if (market.underlying?.address) { - acc[market.underlying.address] = acc[market.underlying.address] || []; - acc[market.underlying.address].push(market); - } - return acc; - }, - {} as Record, - ); + return marketsWithPosition.reduce< + Record + >((acc, market) => { + if (market.underlying?.address) { + acc[market.underlying.address] = acc[market.underlying.address] || []; + acc[market.underlying.address].push(market); + } + return acc; + }, {}); }, ); export const selectLendingPositionsByProtocol = createSelector( selectLendingPositionsWithMarket, (positionsWithMarket) => { - return positionsWithMarket.reduce( - (acc, position) => { - acc[position.protocol] = acc[position.protocol] || []; - acc[position.protocol].push(position); - return acc; - }, - {} as Record, - ); + return positionsWithMarket.reduce< + Record + >((acc, position) => { + acc[position.protocol] = acc[position.protocol] || []; + acc[position.protocol].push(position); + return acc; + }, {}); }, ); export const selectLendingMarketByProtocolAndTokenAddress = createSelector( selectLendingMarketsWithPosition, (marketsWithPosition) => { - return marketsWithPosition.reduce( - (acc, market) => { - if (market.underlying?.address) { - acc[market.protocol] = acc[market.protocol] || {}; - acc[market.protocol][market.underlying.address] = market; - } - return acc; - }, - {} as Record>, - ); + return marketsWithPosition.reduce< + Record> + >((acc, market) => { + if (market.underlying?.address) { + acc[market.protocol] = acc[market.protocol] || {}; + acc[market.protocol][market.underlying.address] = market; + } + return acc; + }, {}); }, ); @@ -177,35 +166,33 @@ export const selectLendingMarketForProtocolAndTokenAddress = ( export const selectLendingMarketsByChainIdAndOutputTokenAddress = createSelector(selectLendingMarketsWithPosition, (marketsWithPosition) => - marketsWithPosition.reduce( - (acc, market) => { - if (market.outputToken?.address) { - acc[market.chainId] = acc?.[market.chainId] || {}; - acc[market.chainId][market.outputToken.address] = - acc?.[market.chainId]?.[market.outputToken.address] || []; - acc[market.chainId][market.outputToken.address].push(market); - } - return acc; - }, - {} as Record>, - ), + marketsWithPosition.reduce< + Record> + >((acc, market) => { + if (market.outputToken?.address) { + acc[market.chainId] = acc?.[market.chainId] || {}; + acc[market.chainId][market.outputToken.address] = + acc?.[market.chainId]?.[market.outputToken.address] || []; + acc[market.chainId][market.outputToken.address].push(market); + } + return acc; + }, {}), ); export const selectLendingMarketsByChainIdAndTokenAddress = createSelector( selectLendingMarketsWithPosition, (marketsWithPosition) => - marketsWithPosition.reduce( - (acc, market) => { - if (market.underlying?.address) { - acc[market.chainId] = acc?.[market.chainId] || {}; - acc[market.chainId][market.underlying.address] = - acc?.[market.chainId]?.[market.underlying.address] || []; - acc[market.chainId][market.underlying.address].push(market); - } - return acc; - }, - {} as Record>, - ), + marketsWithPosition.reduce< + Record> + >((acc, market) => { + if (market.underlying?.address) { + acc[market.chainId] = acc?.[market.chainId] || {}; + acc[market.chainId][market.underlying.address] = + acc?.[market.chainId]?.[market.underlying.address] || []; + acc[market.chainId][market.underlying.address].push(market); + } + return acc; + }, {}), ); export const selectIsLendingEligible = (state: EarnControllerState) => diff --git a/packages/eip-5792-middleware/CHANGELOG.md b/packages/eip-5792-middleware/CHANGELOG.md index 4a7674df95b..439e6eb6eb3 100644 --- a/packages/eip-5792-middleware/CHANGELOG.md +++ b/packages/eip-5792-middleware/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump `@metamask/transaction-controller` from `^61.3.0` to `^62.0.0` ([#7007](https://github.com/MetaMask/core/pull/7007), [#7126](https://github.com/MetaMask/core/pull/7126), [#7153](https://github.com/MetaMask/core/pull/7153), [#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/transaction-controller` from `^61.3.0` to `^62.5.0` ([#7007](https://github.com/MetaMask/core/pull/7007), [#7126](https://github.com/MetaMask/core/pull/7126), [#7153](https://github.com/MetaMask/core/pull/7153), [#7202](https://github.com/MetaMask/core/pull/7202), [#7215](https://github.com/MetaMask/core/pull/7202), [#7220](https://github.com/MetaMask/core/pull/7220), [#7236](https://github.com/MetaMask/core/pull/7236), [#7257](https://github.com/MetaMask/core/pull/7257), [#7289](https://github.com/MetaMask/core/pull/7289), [#7325](https://github.com/MetaMask/core/pull/7325)) ## [2.0.0] diff --git a/packages/eip-5792-middleware/package.json b/packages/eip-5792-middleware/package.json index f8ab8fe56ed..2d496dec8fe 100644 --- a/packages/eip-5792-middleware/package.json +++ b/packages/eip-5792-middleware/package.json @@ -50,7 +50,7 @@ "dependencies": { "@metamask/messenger": "^0.3.0", "@metamask/superstruct": "^3.1.0", - "@metamask/transaction-controller": "^62.0.0", + "@metamask/transaction-controller": "^62.5.0", "@metamask/utils": "^11.8.1", "lodash": "^4.17.21", "uuid": "^8.3.2" diff --git a/packages/eip-5792-middleware/src/hooks/getCallsStatus.test.ts b/packages/eip-5792-middleware/src/hooks/getCallsStatus.test.ts index d2727fb0f69..0626016e078 100644 --- a/packages/eip-5792-middleware/src/hooks/getCallsStatus.test.ts +++ b/packages/eip-5792-middleware/src/hooks/getCallsStatus.test.ts @@ -1,9 +1,5 @@ -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MockAnyNamespace, -} from '@metamask/messenger'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { MessengerActions, MockAnyNamespace } from '@metamask/messenger'; import { TransactionStatus } from '@metamask/transaction-controller'; import type { TransactionControllerGetStateAction, diff --git a/packages/eip-5792-middleware/src/hooks/getCapabilities.test.ts b/packages/eip-5792-middleware/src/hooks/getCapabilities.test.ts index f0ca1cceba4..bc1c765f2df 100644 --- a/packages/eip-5792-middleware/src/hooks/getCapabilities.test.ts +++ b/packages/eip-5792-middleware/src/hooks/getCapabilities.test.ts @@ -3,12 +3,8 @@ import type { AccountsControllerState, } from '@metamask/accounts-controller'; import { KeyringTypes } from '@metamask/keyring-controller'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MockAnyNamespace, - type MessengerActions, -} from '@metamask/messenger'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { MockAnyNamespace, MessengerActions } from '@metamask/messenger'; import type { PreferencesControllerGetStateAction, PreferencesState, diff --git a/packages/eip-5792-middleware/src/hooks/getCapabilities.ts b/packages/eip-5792-middleware/src/hooks/getCapabilities.ts index 4059f6d953a..7fb74ac62ae 100644 --- a/packages/eip-5792-middleware/src/hooks/getCapabilities.ts +++ b/packages/eip-5792-middleware/src/hooks/getCapabilities.ts @@ -109,7 +109,7 @@ export async function getCapabilities( } const status = isSupported ? 'supported' : 'ready'; - const hexChainId = chainId as Hex; + const hexChainId = chainId; if (acc[hexChainId] === undefined) { acc[hexChainId] = {}; @@ -186,7 +186,7 @@ async function getAlternateGasFeesCapability( (isSupported && relaySupportedForChain)); if (alternateGasFees) { - acc[chainId as Hex] = { + acc[chainId] = { alternateGasFees: { supported: true, }, diff --git a/packages/eip-5792-middleware/src/hooks/processSendCalls.test.ts b/packages/eip-5792-middleware/src/hooks/processSendCalls.test.ts index 54fc85de5bb..009b81962d0 100644 --- a/packages/eip-5792-middleware/src/hooks/processSendCalls.test.ts +++ b/packages/eip-5792-middleware/src/hooks/processSendCalls.test.ts @@ -5,12 +5,8 @@ import type { } from '@metamask/accounts-controller'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { - type MessengerActions, - type MockAnyNamespace, - Messenger, - MOCK_ANY_NAMESPACE, -} from '@metamask/messenger'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { MessengerActions, MockAnyNamespace } from '@metamask/messenger'; import type { AutoManagedNetworkClient, CustomNetworkClientConfiguration, diff --git a/packages/eip-5792-middleware/src/methods/wallet_getCallsStatus.ts b/packages/eip-5792-middleware/src/methods/wallet_getCallsStatus.ts index b6137fc8e5a..3023ec9b44b 100644 --- a/packages/eip-5792-middleware/src/methods/wallet_getCallsStatus.ts +++ b/packages/eip-5792-middleware/src/methods/wallet_getCallsStatus.ts @@ -1,7 +1,8 @@ import { rpcErrors } from '@metamask/rpc-errors'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; -import { type GetCallsStatusHook, GetCallsStatusStruct } from '../types'; +import { GetCallsStatusStruct } from '../types'; +import type { GetCallsStatusHook } from '../types'; import { validateParams } from '../utils'; /** diff --git a/packages/eip-5792-middleware/src/methods/wallet_getCapabilities.ts b/packages/eip-5792-middleware/src/methods/wallet_getCapabilities.ts index 3be4441b117..e42ce6bd9af 100644 --- a/packages/eip-5792-middleware/src/methods/wallet_getCapabilities.ts +++ b/packages/eip-5792-middleware/src/methods/wallet_getCapabilities.ts @@ -1,7 +1,8 @@ import { rpcErrors } from '@metamask/rpc-errors'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; -import { type GetCapabilitiesHook, GetCapabilitiesStruct } from '../types'; +import { GetCapabilitiesStruct } from '../types'; +import type { GetCapabilitiesHook } from '../types'; import { validateAndNormalizeKeyholder, validateParams } from '../utils'; /** diff --git a/packages/eip-5792-middleware/src/methods/wallet_sendCalls.ts b/packages/eip-5792-middleware/src/methods/wallet_sendCalls.ts index bea8aee8406..b51d20380c8 100644 --- a/packages/eip-5792-middleware/src/methods/wallet_sendCalls.ts +++ b/packages/eip-5792-middleware/src/methods/wallet_sendCalls.ts @@ -1,11 +1,8 @@ import { rpcErrors } from '@metamask/rpc-errors'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; -import { - type ProcessSendCallsHook, - type SendCallsPayload, - SendCallsStruct, -} from '../types'; +import { SendCallsStruct } from '../types'; +import type { ProcessSendCallsHook, SendCallsPayload } from '../types'; import { validateAndNormalizeKeyholder, validateParams } from '../utils'; /** diff --git a/packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.ts b/packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.ts index 8c918c3a2ef..1438260638e 100644 --- a/packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.ts +++ b/packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.ts @@ -1,9 +1,9 @@ import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors'; -import { - type JsonRpcRequest, - type PendingJsonRpcResponse, - type Hex, - getErrorMessage, +import { getErrorMessage } from '@metamask/utils'; +import type { + JsonRpcRequest, + PendingJsonRpcResponse, + Hex, } from '@metamask/utils'; import { DELEGATION_INDICATOR_PREFIX } from './constants'; @@ -42,7 +42,7 @@ const isAccountUpgraded = async ( } // Extract the 20-byte address (40 hex characters after the prefix) - const upgradedAddress = `0x${code.slice(8, 48)}` as Hex; + const upgradedAddress = `0x${code.slice(8, 48)}` as const; return { isUpgraded: true, upgradedAddress }; }; diff --git a/packages/eip1193-permission-middleware/CHANGELOG.md b/packages/eip1193-permission-middleware/CHANGELOG.md index 5f3c3d1882e..3b80ea89969 100644 --- a/packages/eip1193-permission-middleware/CHANGELOG.md +++ b/packages/eip1193-permission-middleware/CHANGELOG.md @@ -7,11 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.3] + ### Changed - Bump `@metamask/json-rpc-engine` from `^10.1.1` to `^10.2.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) - Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) -- Bump `@metamask/chain-agnostic-permission` from `^1.2.1` to `^1.2.2` ([#6986](https://github.com/MetaMask/core/pull/6986)) +- Bump `@metamask/chain-agnostic-permission` from `^1.2.1` to `^1.3.0` ([#6986](https://github.com/MetaMask/core/pull/6986)) ([#7322](https://github.com/MetaMask/core/pull/7322)) - Bump `@metamask/permission-controller` from `^12.1.0` to `^12.1.1` ([#6988](https://github.com/MetaMask/core/pull/6988), [#7202](https://github.com/MetaMask/core/pull/7202)) ## [1.0.2] @@ -45,7 +47,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/eip1193-permission-middleware@1.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/eip1193-permission-middleware@1.0.3...HEAD +[1.0.3]: https://github.com/MetaMask/core/compare/@metamask/eip1193-permission-middleware@1.0.2...@metamask/eip1193-permission-middleware@1.0.3 [1.0.2]: https://github.com/MetaMask/core/compare/@metamask/eip1193-permission-middleware@1.0.1...@metamask/eip1193-permission-middleware@1.0.2 [1.0.1]: https://github.com/MetaMask/core/compare/@metamask/eip1193-permission-middleware@1.0.0...@metamask/eip1193-permission-middleware@1.0.1 [1.0.0]: https://github.com/MetaMask/core/compare/@metamask/eip1193-permission-middleware@0.1.0...@metamask/eip1193-permission-middleware@1.0.0 diff --git a/packages/eip1193-permission-middleware/package.json b/packages/eip1193-permission-middleware/package.json index 158241c54f3..06003029032 100644 --- a/packages/eip1193-permission-middleware/package.json +++ b/packages/eip1193-permission-middleware/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/eip1193-permission-middleware", - "version": "1.0.2", + "version": "1.0.3", "description": "Implements the JSON-RPC methods for managing permissions as referenced in EIP-2255 and MIP-2 and inspired by MIP-5, but supporting chain-agnostic permission caveats in alignment with @metamask/multichain-api-middleware", "keywords": [ "MetaMask", @@ -48,7 +48,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/chain-agnostic-permission": "^1.2.2", + "@metamask/chain-agnostic-permission": "^1.3.0", "@metamask/controller-utils": "^11.16.0", "@metamask/json-rpc-engine": "^10.2.0", "@metamask/permission-controller": "^12.1.1", diff --git a/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts b/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts index e6fc15be93f..705899e16ab 100644 --- a/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts @@ -8,11 +8,11 @@ import type { AsyncJsonRpcEngineNextCallback, JsonRpcEngineEndCallback, } from '@metamask/json-rpc-engine'; -import { - type CaveatSpecificationConstraint, - MethodNames, - type PermissionController, - type PermissionSpecificationConstraint, +import { MethodNames } from '@metamask/permission-controller'; +import type { + CaveatSpecificationConstraint, + PermissionController, + PermissionSpecificationConstraint, } from '@metamask/permission-controller'; import type { Json, diff --git a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.test.ts b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.test.ts index abb0e0078e9..c14728e930d 100644 --- a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.test.ts +++ b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.test.ts @@ -2,10 +2,8 @@ import { Caip25CaveatType, Caip25EndowmentPermissionName, } from '@metamask/chain-agnostic-permission'; -import { - invalidParams, - type RequestedPermissions, -} from '@metamask/permission-controller'; +import { invalidParams } from '@metamask/permission-controller'; +import type { RequestedPermissions } from '@metamask/permission-controller'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; import { CaveatTypes, EndowmentTypes, RestrictedMethods } from './types'; diff --git a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts index 06fd2b983de..6b882e3dc07 100644 --- a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts @@ -9,15 +9,14 @@ import type { AsyncJsonRpcEngineNextCallback, JsonRpcEngineEndCallback, } from '@metamask/json-rpc-engine'; -import { - type Caveat, - type CaveatSpecificationConstraint, - invalidParams, - MethodNames, - type PermissionController, - type PermissionSpecificationConstraint, - type RequestedPermissions, - type ValidPermission, +import { invalidParams, MethodNames } from '@metamask/permission-controller'; +import type { + Caveat, + CaveatSpecificationConstraint, + PermissionController, + PermissionSpecificationConstraint, + RequestedPermissions, + ValidPermission, } from '@metamask/permission-controller'; import type { Json, diff --git a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts index af9b2ccf2b7..828679498bb 100644 --- a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts @@ -4,11 +4,11 @@ import type { JsonRpcEngineEndCallback, } from '@metamask/json-rpc-engine'; import { invalidParams, MethodNames } from '@metamask/permission-controller'; -import { - isNonEmptyArray, - type Json, - type JsonRpcRequest, - type PendingJsonRpcResponse, +import { isNonEmptyArray } from '@metamask/utils'; +import type { + Json, + JsonRpcRequest, + PendingJsonRpcResponse, } from '@metamask/utils'; import { EndowmentTypes, RestrictedMethods } from './types'; diff --git a/packages/ens-controller/CHANGELOG.md b/packages/ens-controller/CHANGELOG.md index 3ac0ff7eed6..11a35775846 100644 --- a/packages/ens-controller/CHANGELOG.md +++ b/packages/ens-controller/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7258](https://github.com/MetaMask/core/pull/7258)) + - The dependencies moved are: + - `@metamask/network-controller` (^27.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [19.0.0] ### Changed diff --git a/packages/ens-controller/package.json b/packages/ens-controller/package.json index e716f41f3ab..f3db9b0c0f4 100644 --- a/packages/ens-controller/package.json +++ b/packages/ens-controller/package.json @@ -52,12 +52,12 @@ "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.16.0", "@metamask/messenger": "^0.3.0", + "@metamask/network-controller": "^27.0.0", "@metamask/utils": "^11.8.1", "punycode": "^2.1.1" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^26.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -67,9 +67,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/network-controller": "^26.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/ens-controller/src/EnsController.test.ts b/packages/ens-controller/src/EnsController.test.ts index 1a28a326733..66d7464a3c7 100644 --- a/packages/ens-controller/src/EnsController.test.ts +++ b/packages/ens-controller/src/EnsController.test.ts @@ -5,17 +5,16 @@ import { toHex, InfuraNetworkType, } from '@metamask/controller-utils'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; -import { - type NetworkController, - type NetworkState, - getDefaultNetworkControllerState, +import { getDefaultNetworkControllerState } from '@metamask/network-controller'; +import type { + NetworkController, + NetworkState, } from '@metamask/network-controller'; import { EnsController, DEFAULT_ENS_NETWORK_MAP } from './EnsController'; diff --git a/packages/ens-controller/src/EnsController.ts b/packages/ens-controller/src/EnsController.ts index 0f9a1da71c8..1e20c835b29 100644 --- a/packages/ens-controller/src/EnsController.ts +++ b/packages/ens-controller/src/EnsController.ts @@ -1,9 +1,9 @@ import { Web3Provider } from '@ethersproject/providers'; -import { - BaseController, - type StateMetadata, - type ControllerGetStateAction, - type ControllerStateChangeEvent, +import { BaseController } from '@metamask/base-controller'; +import type { + StateMetadata, + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; import type { ChainId } from '@metamask/controller-utils'; import { @@ -214,8 +214,7 @@ export class EnsController extends BaseController< if ( !isSafeDynamicKey(chainId) || !normalizedEnsName || - !this.state.ensEntries[chainId] || - !this.state.ensEntries[chainId][normalizedEnsName] + !this.state.ensEntries[chainId]?.[normalizedEnsName] ) { return false; } @@ -277,10 +276,7 @@ export class EnsController extends BaseController< const normalizedAddress = address ? toChecksumHexAddress(address) : null; const subState = this.state.ensEntries[chainId]; - if ( - subState?.[normalizedEnsName] && - subState[normalizedEnsName].address === normalizedAddress - ) { + if (subState?.[normalizedEnsName]?.address === normalizedAddress) { return false; } @@ -320,8 +316,7 @@ export class EnsController extends BaseController< ); if ( - registriesByChainId && - registriesByChainId[parseInt(currentChainId, 16)] && + registriesByChainId?.[parseInt(currentChainId, 16)] && this.#getChainEnsSupport(currentChainId) ) { this.#ethProvider = new Web3Provider(provider, { diff --git a/packages/error-reporting-service/src/error-reporting-service.test.ts b/packages/error-reporting-service/src/error-reporting-service.test.ts index 47ddaeb2faf..d120bc85ee7 100644 --- a/packages/error-reporting-service/src/error-reporting-service.test.ts +++ b/packages/error-reporting-service/src/error-reporting-service.test.ts @@ -1,8 +1,5 @@ -import { - Messenger, - type MessengerActions, - type MessengerEvents, -} from '@metamask/messenger'; +import { Messenger } from '@metamask/messenger'; +import type { MessengerActions, MessengerEvents } from '@metamask/messenger'; import { captureException as sentryCaptureException } from '@sentry/core'; import type { ErrorReportingServiceMessenger } from './error-reporting-service'; diff --git a/packages/eth-block-tracker/src/PollingBlockTracker.ts b/packages/eth-block-tracker/src/PollingBlockTracker.ts index 1ba5a9f8275..e204bca5979 100644 --- a/packages/eth-block-tracker/src/PollingBlockTracker.ts +++ b/packages/eth-block-tracker/src/PollingBlockTracker.ts @@ -4,12 +4,8 @@ import type { MiddlewareContext, } from '@metamask/json-rpc-engine/v2'; import SafeEventEmitter from '@metamask/safe-event-emitter'; -import { - createDeferredPromise, - type DeferredPromise, - getErrorMessage, - type JsonRpcRequest, -} from '@metamask/utils'; +import { createDeferredPromise, getErrorMessage } from '@metamask/utils'; +import type { DeferredPromise, JsonRpcRequest } from '@metamask/utils'; import getCreateRandomId from 'json-rpc-random-id'; import type { BlockTracker } from './BlockTracker'; diff --git a/packages/eth-block-tracker/tests/withBlockTracker.ts b/packages/eth-block-tracker/tests/withBlockTracker.ts index ca866459f57..17d937892ec 100644 --- a/packages/eth-block-tracker/tests/withBlockTracker.ts +++ b/packages/eth-block-tracker/tests/withBlockTracker.ts @@ -154,7 +154,6 @@ export async function withPollingBlockTracker( callback: WithPollingBlockTrackerCallback, ): Promise; -/* eslint-disable-next-line jsdoc/require-jsdoc */ export async function withPollingBlockTracker( ...args: | [WithPollingBlockTrackerOptions, WithPollingBlockTrackerCallback] diff --git a/packages/eth-json-rpc-middleware/CHANGELOG.md b/packages/eth-json-rpc-middleware/CHANGELOG.md index 3ecb72d5955..9403e497379 100644 --- a/packages/eth-json-rpc-middleware/CHANGELOG.md +++ b/packages/eth-json-rpc-middleware/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [22.0.1] + +### Fixed + +- Include `WalletContext` in EIP-7715 requests ([#7331](https://github.com/MetaMask/core/pull/7331)) + ## [22.0.0] ### Added @@ -47,6 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - See [`MetaMask/eth-json-rpc-middleware`](https://github.com/MetaMask/eth-json-rpc-middleware/blob/main/CHANGELOG.md) for the original changelog. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-middleware@22.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-middleware@22.0.1...HEAD +[22.0.1]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-middleware@22.0.0...@metamask/eth-json-rpc-middleware@22.0.1 [22.0.0]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-middleware@21.0.0...@metamask/eth-json-rpc-middleware@22.0.0 [21.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/eth-json-rpc-middleware@21.0.0 diff --git a/packages/eth-json-rpc-middleware/package.json b/packages/eth-json-rpc-middleware/package.json index 3d60f3f95bb..d0259cf2574 100644 --- a/packages/eth-json-rpc-middleware/package.json +++ b/packages/eth-json-rpc-middleware/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/eth-json-rpc-middleware", - "version": "22.0.0", + "version": "22.0.1", "description": "Ethereum-related json-rpc-engine middleware", "keywords": [ "MetaMask", @@ -70,7 +70,7 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@metamask/error-reporting-service": "^3.0.0", - "@metamask/network-controller": "^26.0.0", + "@metamask/network-controller": "^27.0.0", "@ts-bridge/cli": "^0.6.4", "@types/deep-freeze-strict": "^1.1.0", "@types/jest": "^27.4.1", diff --git a/packages/eth-json-rpc-middleware/src/inflight-cache.ts b/packages/eth-json-rpc-middleware/src/inflight-cache.ts index 0786d9b1224..0934356cbda 100644 --- a/packages/eth-json-rpc-middleware/src/inflight-cache.ts +++ b/packages/eth-json-rpc-middleware/src/inflight-cache.ts @@ -2,11 +2,8 @@ import type { JsonRpcMiddleware, MiddlewareContext, } from '@metamask/json-rpc-engine/v2'; -import { - type Json, - type JsonRpcRequest, - createDeferredPromise, -} from '@metamask/utils'; +import { createDeferredPromise } from '@metamask/utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; import { projectLogger, createModuleLogger } from './logging-utils'; import { cacheIdentifierForRequest } from './utils/cache'; diff --git a/packages/eth-json-rpc-middleware/src/methods/wallet-request-execution-permissions.test.ts b/packages/eth-json-rpc-middleware/src/methods/wallet-request-execution-permissions.test.ts index 71600b8c598..d948d666c7c 100644 --- a/packages/eth-json-rpc-middleware/src/methods/wallet-request-execution-permissions.test.ts +++ b/packages/eth-json-rpc-middleware/src/methods/wallet-request-execution-permissions.test.ts @@ -68,13 +68,14 @@ describe('wallet_requestExecutionPermissions', () => { let request: JsonRpcRequest; let params: RequestExecutionPermissionsRequestParams; let processRequestExecutionPermissionsMock: jest.MockedFunction; + let context: WalletMiddlewareParams['context']; const callMethod = async () => { const handler = createWalletRequestExecutionPermissionsHandler({ processRequestExecutionPermissions: processRequestExecutionPermissionsMock, }); - return handler({ request } as WalletMiddlewareParams); + return handler({ request, context } as WalletMiddlewareParams); }; beforeEach(() => { @@ -83,6 +84,10 @@ describe('wallet_requestExecutionPermissions', () => { request = klona(REQUEST_MOCK); params = request.params as RequestExecutionPermissionsRequestParams; + context = new Map([ + ['origin', 'test-origin'], + ]) as WalletMiddlewareParams['context']; + processRequestExecutionPermissionsMock = jest.fn(); processRequestExecutionPermissionsMock.mockResolvedValue(RESULT_MOCK); }); @@ -92,6 +97,7 @@ describe('wallet_requestExecutionPermissions', () => { expect(processRequestExecutionPermissionsMock).toHaveBeenCalledWith( params, request, + context, ); }); @@ -108,6 +114,7 @@ describe('wallet_requestExecutionPermissions', () => { expect(processRequestExecutionPermissionsMock).toHaveBeenCalledWith( params, request, + context, ); }); @@ -119,6 +126,7 @@ describe('wallet_requestExecutionPermissions', () => { expect(processRequestExecutionPermissionsMock).toHaveBeenCalledWith( params, request, + context, ); }); diff --git a/packages/eth-json-rpc-middleware/src/methods/wallet-request-execution-permissions.ts b/packages/eth-json-rpc-middleware/src/methods/wallet-request-execution-permissions.ts index 3ef8acfec99..cb1c0f17f53 100644 --- a/packages/eth-json-rpc-middleware/src/methods/wallet-request-execution-permissions.ts +++ b/packages/eth-json-rpc-middleware/src/methods/wallet-request-execution-permissions.ts @@ -12,13 +12,8 @@ import { union, unknown, } from '@metamask/superstruct'; -import { - HexChecksumAddressStruct, - type Hex, - type Json, - type JsonRpcRequest, - StrictHexStruct, -} from '@metamask/utils'; +import { HexChecksumAddressStruct, StrictHexStruct } from '@metamask/utils'; +import type { Hex, Json, JsonRpcRequest } from '@metamask/utils'; import { validateParams } from '../utils/validation'; import type { WalletMiddlewareContext } from '../wallet'; @@ -65,6 +60,7 @@ export type RequestExecutionPermissionsResult = Json & export type ProcessRequestExecutionPermissionsHook = ( request: RequestExecutionPermissionsRequestParams, req: JsonRpcRequest, + context: WalletMiddlewareContext, ) => Promise; /** @@ -81,7 +77,7 @@ export function createWalletRequestExecutionPermissionsHandler({ }: { processRequestExecutionPermissions?: ProcessRequestExecutionPermissionsHook; }): JsonRpcMiddleware { - return async ({ request }) => { + return async ({ request, context }) => { if (!processRequestExecutionPermissions) { throw rpcErrors.methodNotSupported( 'wallet_requestExecutionPermissions - no middleware configured', @@ -92,6 +88,6 @@ export function createWalletRequestExecutionPermissionsHandler({ validateParams(params, RequestExecutionPermissionsStruct); - return await processRequestExecutionPermissions(params, request); + return await processRequestExecutionPermissions(params, request, context); }; } diff --git a/packages/eth-json-rpc-middleware/src/methods/wallet-revoke-execution-permission.test.ts b/packages/eth-json-rpc-middleware/src/methods/wallet-revoke-execution-permission.test.ts index a2d2fa2999c..9c12a399db9 100644 --- a/packages/eth-json-rpc-middleware/src/methods/wallet-revoke-execution-permission.test.ts +++ b/packages/eth-json-rpc-middleware/src/methods/wallet-revoke-execution-permission.test.ts @@ -20,12 +20,13 @@ describe('wallet_revokeExecutionPermission', () => { let request: JsonRpcRequest; let params: RevokeExecutionPermissionRequestParams; let processRevokeExecutionPermissionMock: jest.MockedFunction; + let context: WalletMiddlewareParams['context']; const callMethod = async () => { const handler = createWalletRevokeExecutionPermissionHandler({ processRevokeExecutionPermission: processRevokeExecutionPermissionMock, }); - return handler({ request } as WalletMiddlewareParams); + return handler({ request, context } as WalletMiddlewareParams); }; beforeEach(() => { @@ -34,6 +35,10 @@ describe('wallet_revokeExecutionPermission', () => { request = klona(REQUEST_MOCK); params = request.params as RevokeExecutionPermissionRequestParams; + context = new Map([ + ['origin', 'test-origin'], + ]) as WalletMiddlewareParams['context']; + processRevokeExecutionPermissionMock = jest.fn(); processRevokeExecutionPermissionMock.mockResolvedValue({}); }); @@ -43,6 +48,7 @@ describe('wallet_revokeExecutionPermission', () => { expect(processRevokeExecutionPermissionMock).toHaveBeenCalledWith( params, request, + context, ); }); diff --git a/packages/eth-json-rpc-middleware/src/methods/wallet-revoke-execution-permission.ts b/packages/eth-json-rpc-middleware/src/methods/wallet-revoke-execution-permission.ts index 5cf054646a5..fe2f7a6d833 100644 --- a/packages/eth-json-rpc-middleware/src/methods/wallet-revoke-execution-permission.ts +++ b/packages/eth-json-rpc-middleware/src/methods/wallet-revoke-execution-permission.ts @@ -2,8 +2,8 @@ import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/v2'; import { rpcErrors } from '@metamask/rpc-errors'; import type { Infer } from '@metamask/superstruct'; import { object } from '@metamask/superstruct'; -import type { Json } from '@metamask/utils'; -import { type JsonRpcRequest, StrictHexStruct } from '@metamask/utils'; +import { StrictHexStruct } from '@metamask/utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; import { validateParams } from '../utils/validation'; import type { WalletMiddlewareContext } from '../wallet'; @@ -25,6 +25,7 @@ export type RevokeExecutionPermissionRequestParams = Infer< export type ProcessRevokeExecutionPermissionHook = ( request: RevokeExecutionPermissionRequestParams, req: JsonRpcRequest, + context: WalletMiddlewareContext, ) => Promise; /** @@ -41,7 +42,7 @@ export function createWalletRevokeExecutionPermissionHandler({ }: { processRevokeExecutionPermission?: ProcessRevokeExecutionPermissionHook; }): JsonRpcMiddleware { - return async ({ request }) => { + return async ({ request, context }) => { if (!processRevokeExecutionPermission) { throw rpcErrors.methodNotSupported( 'wallet_revokeExecutionPermission - no middleware configured', @@ -52,6 +53,6 @@ export function createWalletRevokeExecutionPermissionHandler({ validateParams(params, RevokeExecutionPermissionRequestParamsStruct); - return await processRevokeExecutionPermission(params, request); + return await processRevokeExecutionPermission(params, request, context); }; } diff --git a/packages/eth-json-rpc-middleware/src/providerAsMiddleware.ts b/packages/eth-json-rpc-middleware/src/providerAsMiddleware.ts index 2ce4ade7e31..b2559f6bd0b 100644 --- a/packages/eth-json-rpc-middleware/src/providerAsMiddleware.ts +++ b/packages/eth-json-rpc-middleware/src/providerAsMiddleware.ts @@ -1,8 +1,6 @@ import type { InternalProvider } from '@metamask/eth-json-rpc-provider'; -import { - createAsyncMiddleware, - type JsonRpcMiddleware as LegacyJsonRpcMiddleware, -} from '@metamask/json-rpc-engine'; +import { createAsyncMiddleware } from '@metamask/json-rpc-engine'; +import type { JsonRpcMiddleware as LegacyJsonRpcMiddleware } from '@metamask/json-rpc-engine'; import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/v2'; import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; diff --git a/packages/eth-json-rpc-middleware/src/wallet.ts b/packages/eth-json-rpc-middleware/src/wallet.ts index 264e90ec5d0..2e1afdcf75c 100644 --- a/packages/eth-json-rpc-middleware/src/wallet.ts +++ b/packages/eth-json-rpc-middleware/src/wallet.ts @@ -10,14 +10,10 @@ import { rpcErrors } from '@metamask/rpc-errors'; import { isValidHexAddress } from '@metamask/utils'; import type { JsonRpcRequest, Json, Hex } from '@metamask/utils'; -import { - createWalletRequestExecutionPermissionsHandler, - type ProcessRequestExecutionPermissionsHook, -} from './methods/wallet-request-execution-permissions'; -import { - type ProcessRevokeExecutionPermissionHook, - createWalletRevokeExecutionPermissionHandler, -} from './methods/wallet-revoke-execution-permission'; +import { createWalletRequestExecutionPermissionsHandler } from './methods/wallet-request-execution-permissions'; +import type { ProcessRequestExecutionPermissionsHook } from './methods/wallet-request-execution-permissions'; +import { createWalletRevokeExecutionPermissionHandler } from './methods/wallet-revoke-execution-permission'; +import type { ProcessRevokeExecutionPermissionHook } from './methods/wallet-revoke-execution-permission'; import { stripArrayTypeIfPresent } from './utils/common'; import { normalizeTypedMessage, parseTypedMessage } from './utils/normalize'; import { diff --git a/packages/eth-json-rpc-provider/src/internal-provider.test.ts b/packages/eth-json-rpc-provider/src/internal-provider.test.ts index 4a9ea84ea0b..248685653f6 100644 --- a/packages/eth-json-rpc-provider/src/internal-provider.test.ts +++ b/packages/eth-json-rpc-provider/src/internal-provider.test.ts @@ -8,7 +8,7 @@ import type { } from '@metamask/json-rpc-engine/v2'; import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; -import { type JsonRpcRequest, type Json } from '@metamask/utils'; +import type { JsonRpcRequest, Json } from '@metamask/utils'; import { BrowserProvider } from 'ethers'; import { promisify } from 'util'; diff --git a/packages/eth-json-rpc-provider/src/internal-provider.ts b/packages/eth-json-rpc-provider/src/internal-provider.ts index fe8daee042c..fd38838472c 100644 --- a/packages/eth-json-rpc-provider/src/internal-provider.ts +++ b/packages/eth-json-rpc-provider/src/internal-provider.ts @@ -1,17 +1,18 @@ -import { asV2Middleware, type JsonRpcEngine } from '@metamask/json-rpc-engine'; -import { - type HandleOptions, - type ContextConstraint, - type MiddlewareContext, - JsonRpcEngineV2, +import { asV2Middleware } from '@metamask/json-rpc-engine'; +import type { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import type { + HandleOptions, + ContextConstraint, + MiddlewareContext, } from '@metamask/json-rpc-engine/v2'; -import type { JsonRpcSuccess } from '@metamask/utils'; -import { - type Json, - type JsonRpcId, - type JsonRpcParams, - type JsonRpcRequest, - type JsonRpcVersion2, +import type { + Json, + JsonRpcId, + JsonRpcParams, + JsonRpcSuccess, + JsonRpcRequest, + JsonRpcVersion2, } from '@metamask/utils'; import { nanoid } from 'nanoid'; diff --git a/packages/eth-json-rpc-provider/src/provider-from-middleware.ts b/packages/eth-json-rpc-provider/src/provider-from-middleware.ts index 123e2772f29..f3ce178e58f 100644 --- a/packages/eth-json-rpc-provider/src/provider-from-middleware.ts +++ b/packages/eth-json-rpc-provider/src/provider-from-middleware.ts @@ -1,13 +1,11 @@ import { asV2Middleware } from '@metamask/json-rpc-engine'; import type { JsonRpcMiddleware as LegacyJsonRpcMiddleware } from '@metamask/json-rpc-engine'; +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; import type { + ContextConstraint, JsonRpcMiddleware, ResultConstraint, } from '@metamask/json-rpc-engine/v2'; -import { - JsonRpcEngineV2, - type ContextConstraint, -} from '@metamask/json-rpc-engine/v2'; import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; import { InternalProvider } from './internal-provider'; diff --git a/packages/foundryup/src/download.ts b/packages/foundryup/src/download.ts index 569397d2283..3958694702d 100644 --- a/packages/foundryup/src/download.ts +++ b/packages/foundryup/src/download.ts @@ -1,4 +1,5 @@ -import { request as httpRequest, type IncomingMessage } from 'node:http'; +import { request as httpRequest } from 'node:http'; +import type { IncomingMessage } from 'node:http'; import { request as httpsRequest } from 'node:https'; import { Stream } from 'node:stream'; import { pipeline } from 'node:stream/promises'; diff --git a/packages/foundryup/src/extract.ts b/packages/foundryup/src/extract.ts index e8e085d258c..3cb213d9d97 100644 --- a/packages/foundryup/src/extract.ts +++ b/packages/foundryup/src/extract.ts @@ -8,10 +8,12 @@ import { Agent as HttpsAgent } from 'node:https'; import { join, basename, extname, relative } from 'node:path'; import { pipeline } from 'node:stream/promises'; import { extract as extractTar } from 'tar'; -import { Open, type Source, type Entry } from 'unzipper'; +import { Open } from 'unzipper'; +import type { Source, Entry } from 'unzipper'; import { startDownload } from './download'; -import { Extension, type Binary } from './types'; +import { Extension } from './types'; +import type { Binary } from './types'; import { say } from './utils'; /** diff --git a/packages/foundryup/src/options.ts b/packages/foundryup/src/options.ts index 9fe49de2e74..a0666b89edc 100644 --- a/packages/foundryup/src/options.ts +++ b/packages/foundryup/src/options.ts @@ -2,15 +2,13 @@ import { platform } from 'node:os'; import { argv, stdout } from 'node:process'; import yargs from 'yargs/yargs'; -import { - type Checksums, - type ParsedOptions, - type ArchitecturesTuple, - type BinariesTuple, - type PlatformsTuple, - Architecture, - Binary, - Platform, +import { Architecture, Binary, Platform } from './types'; +import type { + Checksums, + ParsedOptions, + ArchitecturesTuple, + BinariesTuple, + PlatformsTuple, } from './types'; import { normalizeSystemArchitecture } from './utils'; @@ -155,7 +153,7 @@ function getOptions( alias: 'a', description: 'Specify the architecture', // if `defaultArch` is not a supported Architecture yargs will throw an error - default: defaultArch as Architecture, + default: defaultArch, choices: Object.values(Architecture) as ArchitecturesTuple, }, platform: { diff --git a/packages/foundryup/src/utils.ts b/packages/foundryup/src/utils.ts index fd50af554e8..1b9c33092c6 100644 --- a/packages/foundryup/src/utils.ts +++ b/packages/foundryup/src/utils.ts @@ -1,12 +1,12 @@ import { execFileSync, execSync } from 'node:child_process'; import { arch } from 'node:os'; -import { - type Checksums, - type PlatformArchChecksums, - Architecture, - type Binary, - type Platform, +import { Architecture } from './types'; +import type { + Checksums, + PlatformArchChecksums, + Binary, + Platform, } from './types'; /** diff --git a/packages/gas-fee-controller/CHANGELOG.md b/packages/gas-fee-controller/CHANGELOG.md index 06e66f10a13..76af90d09e0 100644 --- a/packages/gas-fee-controller/CHANGELOG.md +++ b/packages/gas-fee-controller/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7258](https://github.com/MetaMask/core/pull/7258)) + - The dependencies moved are: + - `@metamask/network-controller` (^27.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [26.0.0] ### Changed diff --git a/packages/gas-fee-controller/package.json b/packages/gas-fee-controller/package.json index 2df9a6e8d5c..8f07adbb761 100644 --- a/packages/gas-fee-controller/package.json +++ b/packages/gas-fee-controller/package.json @@ -52,6 +52,7 @@ "@metamask/controller-utils": "^11.16.0", "@metamask/eth-query": "^4.0.0", "@metamask/ethjs-unit": "^0.3.0", + "@metamask/network-controller": "^27.0.0", "@metamask/polling-controller": "^16.0.0", "@metamask/utils": "^11.8.1", "@types/bn.js": "^5.1.5", @@ -62,7 +63,6 @@ "devDependencies": { "@babel/runtime": "^7.23.9", "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^26.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "@types/jest-when": "^2.7.3", @@ -77,8 +77,7 @@ "typescript": "~5.3.3" }, "peerDependencies": { - "@babel/runtime": "^7.0.0", - "@metamask/network-controller": "^26.0.0" + "@babel/runtime": "^7.0.0" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/gas-fee-controller/src/GasFeeController.test.ts b/packages/gas-fee-controller/src/GasFeeController.test.ts index aa69131f30b..2ee0746a983 100644 --- a/packages/gas-fee-controller/src/GasFeeController.test.ts +++ b/packages/gas-fee-controller/src/GasFeeController.test.ts @@ -5,12 +5,11 @@ import { toHex, } from '@metamask/controller-utils'; import EthQuery from '@metamask/eth-query'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { NetworkController, NetworkStatus } from '@metamask/network-controller'; import type { diff --git a/packages/gator-permissions-controller/CHANGELOG.md b/packages/gator-permissions-controller/CHANGELOG.md index 31d1994b875..c8e6b0949fb 100644 --- a/packages/gator-permissions-controller/CHANGELOG.md +++ b/packages/gator-permissions-controller/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.0] + +### Added + +- Export `DELEGATION_FRAMEWORK_VERSION` constant to indicate the supported Delegation Framework version ([#7195](https://github.com/MetaMask/core/pull/7195)) + +### Changed + +- **BREAKING:** Permission decoding now rejects `TimestampEnforcer` caveats with zero `timestampBeforeThreshold` values ([#7195](https://github.com/MetaMask/core/pull/7195)) +- `PermissionResponseSanitized` now includes `rules` property for stronger typing support ([#7195](https://github.com/MetaMask/core/pull/7195)) +- Permission decoding now resolves `erc20-token-revocation` permission type ([#7299](https://github.com/MetaMask/core/pull/7299)) +- Differentiate `erc20-token-revocation` permissions from `other` in controller state ([#7318](https://github.com/MetaMask/core/pull/7318)) +- Bump `@metamask/transaction-controller` from `^62.3.1` to `^62.5.0` ([#7289](https://github.com/MetaMask/core/pull/7289), [#7325](https://github.com/MetaMask/core/pull/7325)) + +## [0.7.0] + +### Added + +- Refresh gator permissions map after revocation state change ([#7235](https://github.com/MetaMask/core/pull/7235)) +- New `submitDirectRevocation` method for already-disabled delegations that don't require an on-chain transaction ([#7244](https://github.com/MetaMask/core/pull/7244)) + +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7220](https://github.com/MetaMask/core/pull/7220), [#7236](https://github.com/MetaMask/core/pull/7236), [#7257](https://github.com/MetaMask/core/pull/7257)) + - The dependencies moved are: + - `@metamask/snaps-controllers` (^14.0.1) + - `@metamask/transaction-controller` (^62.3.1) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [0.6.0] ### Changed @@ -80,7 +111,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#6033](https://github.com/MetaMask/core/pull/6033)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.6.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.8.0...HEAD +[0.8.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.7.0...@metamask/gator-permissions-controller@0.8.0 +[0.7.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.6.0...@metamask/gator-permissions-controller@0.7.0 [0.6.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.5.0...@metamask/gator-permissions-controller@0.6.0 [0.5.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.4.0...@metamask/gator-permissions-controller@0.5.0 [0.4.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.3.0...@metamask/gator-permissions-controller@0.4.0 diff --git a/packages/gator-permissions-controller/package.json b/packages/gator-permissions-controller/package.json index fcb904ce4de..2c315c32658 100644 --- a/packages/gator-permissions-controller/package.json +++ b/packages/gator-permissions-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/gator-permissions-controller", - "version": "0.6.0", + "version": "0.8.0", "description": "Controller for managing gator permissions with profile sync integration", "keywords": [ "MetaMask", @@ -48,21 +48,21 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/7715-permission-types": "^0.3.0", + "@metamask/7715-permission-types": "^0.4.0", "@metamask/base-controller": "^9.0.0", "@metamask/delegation-core": "^0.2.0", "@metamask/delegation-deployments": "^0.12.0", "@metamask/messenger": "^0.3.0", + "@metamask/snaps-controllers": "^14.0.1", "@metamask/snaps-sdk": "^9.0.0", "@metamask/snaps-utils": "^11.0.0", + "@metamask/transaction-controller": "^62.5.0", "@metamask/utils": "^11.8.1" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/snaps-controllers": "^14.0.1", - "@metamask/transaction-controller": "^62.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -72,10 +72,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/snaps-controllers": "^14.0.1", - "@metamask/transaction-controller": "^62.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts index 7fd4b0e9e84..a46ca7879c7 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts @@ -9,22 +9,22 @@ import { CHAIN_ID, DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { HandleSnapRequest, HasSnap } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; import type { TransactionMeta } from '@metamask/transaction-controller'; -import { hexToBigInt, numberToHex, type Hex } from '@metamask/utils'; +import { hexToBigInt, numberToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; +import { DELEGATION_FRAMEWORK_VERSION } from './constants'; +import { GatorPermissionsFetchError } from './errors'; import type { GatorPermissionsControllerMessenger } from './GatorPermissionsController'; -import GatorPermissionsController, { - DELEGATION_FRAMEWORK_VERSION, -} from './GatorPermissionsController'; +import GatorPermissionsController from './GatorPermissionsController'; import { mockCustomPermissionStorageEntry, mockErc20TokenPeriodicStorageEntry, @@ -39,6 +39,7 @@ import type { PermissionTypesWithCustom, RevocationParams, } from './types'; +import { flushPromises } from '../../../tests/helpers'; const MOCK_CHAIN_ID_1: Hex = '0xaa36a7'; const MOCK_CHAIN_ID_2: Hex = '0x1'; @@ -94,6 +95,7 @@ describe('GatorPermissionsController', () => { expect(controller.state.isGatorPermissionsEnabled).toBe(false); expect(controller.state.gatorPermissionsMapSerialized).toStrictEqual( JSON.stringify({ + 'erc20-token-revocation': {}, 'native-token-stream': {}, 'native-token-periodic': {}, 'erc20-token-stream': {}, @@ -170,6 +172,7 @@ describe('GatorPermissionsController', () => { expect(controller.state.isGatorPermissionsEnabled).toBe(false); expect(controller.state.gatorPermissionsMapSerialized).toBe( JSON.stringify({ + 'erc20-token-revocation': {}, 'native-token-stream': {}, 'native-token-periodic': {}, 'erc20-token-stream': {}, @@ -182,8 +185,38 @@ describe('GatorPermissionsController', () => { describe('fetchAndUpdateGatorPermissions', () => { it('fetches and updates gator permissions successfully', async () => { + // Create mock data with rules to verify they are preserved + const mockStorageEntriesWithRules = [ + ...MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES, + { + ...mockNativeTokenStreamStorageEntry(MOCK_CHAIN_ID_1), + permissionResponse: { + ...mockNativeTokenStreamStorageEntry(MOCK_CHAIN_ID_1) + .permissionResponse, + rules: [ + { + type: 'test-rule', + isAdjustmentAllowed: false, + data: { + target: '0x1234567890123456789012345678901234567890', + sig: '0xabcd', + expiry: 1735689600, // Example expiry timestamp + }, + }, + ], + }, + }, + ]; + + const mockHandleRequestHandler = jest + .fn() + .mockResolvedValue(mockStorageEntriesWithRules); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const controller = new GatorPermissionsController({ - messenger: getGatorPermissionsControllerMessenger(), + messenger: getGatorPermissionsControllerMessenger(rootMessenger), }); await controller.enableGatorPermissions(); @@ -191,6 +224,7 @@ describe('GatorPermissionsController', () => { const result = await controller.fetchAndUpdateGatorPermissions(); expect(result).toStrictEqual({ + 'erc20-token-revocation': expect.any(Object), 'native-token-stream': expect.any(Object), 'native-token-periodic': expect.any(Object), 'erc20-token-stream': expect.any(Object), @@ -199,7 +233,9 @@ describe('GatorPermissionsController', () => { }); // Check that each permission type has the expected chainId - expect(result['native-token-stream'][MOCK_CHAIN_ID_1]).toHaveLength(5); + expect( + result['native-token-stream'][MOCK_CHAIN_ID_1].length, + ).toBeGreaterThanOrEqual(5); expect(result['native-token-periodic'][MOCK_CHAIN_ID_1]).toHaveLength(5); expect(result['erc20-token-stream'][MOCK_CHAIN_ID_1]).toHaveLength(5); expect(result['native-token-stream'][MOCK_CHAIN_ID_2]).toHaveLength(5); @@ -217,7 +253,6 @@ describe('GatorPermissionsController', () => { flattenedStoredGatorPermissions.forEach((permission) => { expect(permission.permissionResponse.signer).toBeUndefined(); expect(permission.permissionResponse.dependencyInfo).toBeUndefined(); - expect(permission.permissionResponse.rules).toBeUndefined(); }); }; @@ -225,7 +260,67 @@ describe('GatorPermissionsController', () => { sanitizedCheck('native-token-periodic'); sanitizedCheck('erc20-token-stream'); sanitizedCheck('erc20-token-periodic'); + sanitizedCheck('erc20-token-revocation'); sanitizedCheck('other'); + + // Specifically verify that the entry with rules has rules preserved + const entryWithRules = result['native-token-stream'][ + MOCK_CHAIN_ID_1 + ].find((entry) => entry.permissionResponse.rules !== undefined); + expect(entryWithRules).toBeDefined(); + expect(entryWithRules?.permissionResponse.rules).toBeDefined(); + expect(entryWithRules?.permissionResponse.rules).toStrictEqual([ + { + type: 'test-rule', + isAdjustmentAllowed: false, + data: { + target: '0x1234567890123456789012345678901234567890', + sig: '0xabcd', + expiry: 1735689600, + }, + }, + ]); + }); + + it('categorizes erc20-token-revocation permissions into its own bucket', async () => { + const chainId = '0x1' as Hex; + // Create a minimal revocation permission entry and cast to satisfy types + const revocationEntry = { + permissionResponse: { + chainId, + address: '0x0000000000000000000000000000000000000001', + signer: { + type: 'account', + data: { address: '0x0000000000000000000000000000000000000002' }, + }, + permission: { + type: 'erc20-token-revocation', + isAdjustmentAllowed: false, + // Data shape is enforced by external types; not relevant for categorization + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: {} as any, + }, + context: '0xdeadbeef', + dependencyInfo: [], + signerMeta: { + delegationManager: '0x0000000000000000000000000000000000000003', + }, + }, + siteOrigin: 'https://example.org', + } as unknown; + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: async () => + [revocationEntry] as unknown, + }); + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + }); + + await controller.enableGatorPermissions(); + const result = await controller.fetchAndUpdateGatorPermissions(); + + expect(result['erc20-token-revocation']).toBeDefined(); + expect(result['erc20-token-revocation'][chainId]).toHaveLength(1); }); it('throws error when gator permissions are not enabled', async () => { @@ -254,6 +349,7 @@ describe('GatorPermissionsController', () => { const result = await controller.fetchAndUpdateGatorPermissions(); expect(result).toStrictEqual({ + 'erc20-token-revocation': {}, 'native-token-stream': {}, 'native-token-periodic': {}, 'erc20-token-stream': {}, @@ -276,6 +372,7 @@ describe('GatorPermissionsController', () => { const result = await controller.fetchAndUpdateGatorPermissions(); expect(result).toStrictEqual({ + 'erc20-token-revocation': {}, 'native-token-stream': {}, 'native-token-periodic': {}, 'erc20-token-stream': {}, @@ -340,6 +437,7 @@ describe('GatorPermissionsController', () => { const { gatorPermissionsMap } = controller; expect(gatorPermissionsMap).toStrictEqual({ + 'erc20-token-revocation': {}, 'native-token-stream': {}, 'native-token-periodic': {}, 'erc20-token-stream': {}, @@ -451,7 +549,7 @@ describe('GatorPermissionsController', () => { ), ).toMatchInlineSnapshot(` Object { - "gatorPermissionsMapSerialized": "{\\"native-token-stream\\":{},\\"native-token-periodic\\":{},\\"erc20-token-stream\\":{},\\"erc20-token-periodic\\":{},\\"other\\":{}}", + "gatorPermissionsMapSerialized": "{\\"erc20-token-revocation\\":{},\\"native-token-stream\\":{},\\"native-token-periodic\\":{},\\"erc20-token-stream\\":{},\\"erc20-token-periodic\\":{},\\"other\\":{}}", "gatorPermissionsProviderSnapId": "npm:@metamask/gator-permissions-snap", "isFetchingGatorPermissions": false, "isGatorPermissionsEnabled": false, @@ -473,7 +571,7 @@ describe('GatorPermissionsController', () => { ), ).toMatchInlineSnapshot(` Object { - "gatorPermissionsMapSerialized": "{\\"native-token-stream\\":{},\\"native-token-periodic\\":{},\\"erc20-token-stream\\":{},\\"erc20-token-periodic\\":{},\\"other\\":{}}", + "gatorPermissionsMapSerialized": "{\\"erc20-token-revocation\\":{},\\"native-token-stream\\":{},\\"native-token-periodic\\":{},\\"erc20-token-stream\\":{},\\"erc20-token-periodic\\":{},\\"other\\":{}}", "isGatorPermissionsEnabled": false, } `); @@ -492,7 +590,7 @@ describe('GatorPermissionsController', () => { ), ).toMatchInlineSnapshot(` Object { - "gatorPermissionsMapSerialized": "{\\"native-token-stream\\":{},\\"native-token-periodic\\":{},\\"erc20-token-stream\\":{},\\"erc20-token-periodic\\":{},\\"other\\":{}}", + "gatorPermissionsMapSerialized": "{\\"erc20-token-revocation\\":{},\\"native-token-stream\\":{},\\"native-token-periodic\\":{},\\"erc20-token-stream\\":{},\\"erc20-token-periodic\\":{},\\"other\\":{}}", "pendingRevocations": Array [], } `); @@ -813,6 +911,261 @@ describe('GatorPermissionsController', () => { 'Failed to handle snap request to gator permissions provider for method permissionsProvider_submitRevocation', ); }); + + it('should clear pending revocation in finally block even if refresh fails', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const messenger = getMessenger( + getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }), + ); + + const controller = new GatorPermissionsController({ + messenger, + state: { + isGatorPermissionsEnabled: true, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + pendingRevocations: [ + { + txId: 'test-tx-id', + permissionContext: '0x1234567890abcdef1234567890abcdef12345678', + }, + ], + }, + }); + + // Mock fetchAndUpdateGatorPermissions to fail with GatorPermissionsFetchError + // (which is what it actually throws in real scenarios) + const fetchError = new GatorPermissionsFetchError({ + message: 'Failed to fetch gator permissions', + cause: new Error('Refresh failed'), + }); + jest + .spyOn(controller, 'fetchAndUpdateGatorPermissions') + .mockRejectedValue(fetchError); + + const revocationParams: RevocationParams = { + permissionContext: '0x1234567890abcdef1234567890abcdef12345678', + }; + + // Should throw GatorPermissionsFetchError (not GatorPermissionsProviderError) + // because revocation succeeded but refresh failed + await expect( + controller.submitRevocation(revocationParams), + ).rejects.toThrow(GatorPermissionsFetchError); + + // Verify the error message indicates refresh failure, not revocation failure + await expect( + controller.submitRevocation(revocationParams), + ).rejects.toThrow( + 'Failed to refresh permissions list after successful revocation', + ); + + // Pending revocation should still be cleared despite refresh failure + expect(controller.pendingRevocations).toStrictEqual([]); + }); + }); + + describe('submitDirectRevocation', () => { + it('should add to pending revocations and immediately submit revocation', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const messenger = getMessenger( + getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }), + ); + + const controller = new GatorPermissionsController({ + messenger, + state: { + isGatorPermissionsEnabled: true, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const revocationParams: RevocationParams = { + permissionContext: '0x1234567890abcdef1234567890abcdef12345678', + }; + + await controller.submitDirectRevocation(revocationParams); + + // Should have called submitRevocation + expect(mockHandleRequestHandler).toHaveBeenCalledWith({ + snapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + origin: 'metamask', + handler: 'onRpcRequest', + request: { + jsonrpc: '2.0', + method: 'permissionsProvider_submitRevocation', + params: revocationParams, + }, + }); + + // Pending revocation should be cleared after successful submission + expect(controller.pendingRevocations).toStrictEqual([]); + }); + + it('should add pending revocation with placeholder txId', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const messenger = getMessenger( + getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }), + ); + + const controller = new GatorPermissionsController({ + messenger, + state: { + isGatorPermissionsEnabled: true, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const permissionContext = + '0x1234567890abcdef1234567890abcdef12345678' as Hex; + const revocationParams: RevocationParams = { + permissionContext, + }; + + // Spy on submitRevocation to check pending state before it's called + const submitRevocationSpy = jest.spyOn(controller, 'submitRevocation'); + + await controller.submitDirectRevocation(revocationParams); + + // Verify that pending revocation was added (before submitRevocation clears it) + // We check by verifying submitRevocation was called, which clears pending + expect(submitRevocationSpy).toHaveBeenCalledWith(revocationParams); + expect(controller.pendingRevocations).toStrictEqual([]); + }); + + it('should throw GatorPermissionsNotEnabledError when gator permissions are disabled', async () => { + const messenger = getMessenger(); + const controller = new GatorPermissionsController({ + messenger, + state: { + isGatorPermissionsEnabled: false, + }, + }); + + const revocationParams: RevocationParams = { + permissionContext: '0x1234567890abcdef1234567890abcdef12345678', + }; + + await expect( + controller.submitDirectRevocation(revocationParams), + ).rejects.toThrow('Gator permissions are not enabled'); + }); + + it('should clear pending revocation even if submitRevocation fails (finally block)', async () => { + const mockHandleRequestHandler = jest + .fn() + .mockRejectedValue(new Error('Snap request failed')); + const messenger = getMessenger( + getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }), + ); + + const controller = new GatorPermissionsController({ + messenger, + state: { + isGatorPermissionsEnabled: true, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const permissionContext = + '0x1234567890abcdef1234567890abcdef12345678' as Hex; + const revocationParams: RevocationParams = { + permissionContext, + }; + + await expect( + controller.submitDirectRevocation(revocationParams), + ).rejects.toThrow( + 'Failed to handle snap request to gator permissions provider for method permissionsProvider_submitRevocation', + ); + + // Pending revocation is cleared in finally block even if submission failed + // This prevents stuck state, though the error is still thrown for caller handling + expect(controller.pendingRevocations).toStrictEqual([]); + }); + }); + + describe('isPendingRevocation', () => { + it('should return true when permission context is in pending revocations', () => { + const messenger = getMessenger(); + const permissionContext = + '0x1234567890abcdef1234567890abcdef12345678' as Hex; + const controller = new GatorPermissionsController({ + messenger, + state: { + isGatorPermissionsEnabled: true, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + pendingRevocations: [ + { + txId: 'test-tx-id', + permissionContext, + }, + ], + }, + }); + + expect(controller.isPendingRevocation(permissionContext)).toBe(true); + }); + + it('should return false when permission context is not in pending revocations', () => { + const messenger = getMessenger(); + const controller = new GatorPermissionsController({ + messenger, + state: { + isGatorPermissionsEnabled: true, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + pendingRevocations: [ + { + txId: 'test-tx-id', + permissionContext: '0x1234567890abcdef1234567890abcdef12345678', + }, + ], + }, + }); + + expect( + controller.isPendingRevocation( + '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef' as Hex, + ), + ).toBe(false); + }); + + it('should be case-insensitive when checking permission context', () => { + const messenger = getMessenger(); + const permissionContext = + '0x1234567890abcdef1234567890abcdef12345678' as Hex; + const controller = new GatorPermissionsController({ + messenger, + state: { + isGatorPermissionsEnabled: true, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + pendingRevocations: [ + { + txId: 'test-tx-id', + permissionContext: permissionContext.toLowerCase() as Hex, + }, + ], + }, + }); + + expect( + controller.isPendingRevocation(permissionContext.toUpperCase() as Hex), + ).toBe(true); + }); }); describe('addPendingRevocation', () => { @@ -855,9 +1208,9 @@ describe('GatorPermissionsController', () => { id: txId, } as TransactionMeta); - // Wait for async operations - await Promise.resolve(); + await flushPromises(); + // Verify submitRevocation was called expect(mockHandleRequestHandler).toHaveBeenCalledWith({ snapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, origin: 'metamask', @@ -868,6 +1221,18 @@ describe('GatorPermissionsController', () => { params: { permissionContext }, }, }); + + // Verify that permissions are refreshed after revocation (getGrantedPermissions is called) + expect(mockHandleRequestHandler).toHaveBeenCalledWith({ + snapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + origin: 'metamask', + handler: 'onRpcRequest', + request: { + jsonrpc: '2.0', + method: 'permissionsProvider_getGrantedPermissions', + params: { isRevoked: false }, + }, + }); }); it('should cleanup without adding to state when transaction is rejected by user', async () => { @@ -908,7 +1273,7 @@ describe('GatorPermissionsController', () => { expect(controller.pendingRevocations).toStrictEqual([]); }); - it('should cleanup without submitting revocation when transaction fails', async () => { + it('should cleanup and refresh permissions without submitting revocation when transaction fails', async () => { const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); const rootMessenger = getRootMessenger({ snapControllerHandleRequestActionHandler: mockHandleRequestHandler, @@ -938,11 +1303,23 @@ describe('GatorPermissionsController', () => { // Wait for async operations await Promise.resolve(); - // Should not call submitRevocation - expect(mockHandleRequestHandler).not.toHaveBeenCalled(); + // Should refresh permissions with isRevoked: false + expect(mockHandleRequestHandler).toHaveBeenCalledWith({ + handler: 'onRpcRequest', + origin: 'metamask', + request: { + jsonrpc: '2.0', + method: 'permissionsProvider_getGrantedPermissions', + params: { isRevoked: false }, + }, + snapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }); + + // Should not be in pending revocations + expect(controller.pendingRevocations).toStrictEqual([]); }); - it('should cleanup without submitting revocation when transaction is dropped', async () => { + it('should cleanup and refresh permissions without submitting revocation when transaction is dropped', async () => { const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); const rootMessenger = getRootMessenger({ snapControllerHandleRequestActionHandler: mockHandleRequestHandler, @@ -971,8 +1348,97 @@ describe('GatorPermissionsController', () => { // Wait for async operations await Promise.resolve(); - // Should not call submitRevocation - expect(mockHandleRequestHandler).not.toHaveBeenCalled(); + // Should refresh permissions with isRevoked: false + expect(mockHandleRequestHandler).toHaveBeenCalledWith({ + handler: 'onRpcRequest', + origin: 'metamask', + request: { + jsonrpc: '2.0', + method: 'permissionsProvider_getGrantedPermissions', + params: { isRevoked: false }, + }, + snapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }); + + // Should not be in pending revocations + expect(controller.pendingRevocations).toStrictEqual([]); + }); + + it('should handle error when refreshing permissions after transaction fails', async () => { + const mockError = new Error('Failed to fetch permissions'); + const mockHandleRequestHandler = jest.fn().mockRejectedValue(mockError); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + const controller = new GatorPermissionsController({ + messenger, + state: { + isGatorPermissionsEnabled: true, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const txId = 'test-tx-id'; + const permissionContext = '0x1234567890abcdef1234567890abcdef12345678'; + + await controller.addPendingRevocation({ txId, permissionContext }); + + // Emit transaction failed event + rootMessenger.publish('TransactionController:transactionFailed', { + transactionMeta: { id: txId } as TransactionMeta, + error: 'Transaction failed', + }); + + // Wait for async operations and catch blocks to execute + await Promise.resolve(); + await Promise.resolve(); + + // Should have attempted to refresh permissions + expect(mockHandleRequestHandler).toHaveBeenCalled(); + + // Should not be in pending revocations + expect(controller.pendingRevocations).toStrictEqual([]); + }); + + it('should handle error when refreshing permissions after transaction is dropped', async () => { + const mockError = new Error('Failed to fetch permissions'); + const mockHandleRequestHandler = jest.fn().mockRejectedValue(mockError); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + const controller = new GatorPermissionsController({ + messenger, + state: { + isGatorPermissionsEnabled: true, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const txId = 'test-tx-id'; + const permissionContext = '0x1234567890abcdef1234567890abcdef12345678'; + + await controller.addPendingRevocation({ txId, permissionContext }); + + // Emit transaction dropped event + rootMessenger.publish('TransactionController:transactionDropped', { + transactionMeta: { id: txId } as TransactionMeta, + }); + + // Wait for async operations and catch blocks to execute + await Promise.resolve(); + await Promise.resolve(); + + // Should have attempted to refresh permissions + expect(mockHandleRequestHandler).toHaveBeenCalled(); + + // Should not be in pending revocations + expect(controller.pendingRevocations).toStrictEqual([]); }); it('should cleanup without submitting revocation when timeout is reached', async () => { diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.ts index 4c5124f0b22..de0756c849d 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.ts @@ -19,6 +19,7 @@ import type { } from '@metamask/transaction-controller'; import type { Hex, Json } from '@metamask/utils'; +import { DELEGATION_FRAMEWORK_VERSION } from './constants'; import type { DecodedPermission } from './decodePermission'; import { getPermissionDataAndExpiry, @@ -33,15 +34,15 @@ import { PermissionDecodingError, } from './errors'; import { controllerLog } from './logger'; +import { GatorPermissionsSnapRpcMethod } from './types'; import type { StoredGatorPermissionSanitized } from './types'; -import { - GatorPermissionsSnapRpcMethod, - type GatorPermissionsMap, - type PermissionTypesWithCustom, - type StoredGatorPermission, - type DelegationDetails, - type RevocationParams, - type PendingRevocationParams, +import type { + GatorPermissionsMap, + PermissionTypesWithCustom, + StoredGatorPermission, + DelegationDetails, + RevocationParams, + PendingRevocationParams, } from './types'; import { deserializeGatorPermissionsMap, @@ -57,20 +58,17 @@ const controllerName = 'GatorPermissionsController'; const defaultGatorPermissionsProviderSnapId = 'npm:@metamask/gator-permissions-snap' as SnapId; -const defaultGatorPermissionsMap: GatorPermissionsMap = { - 'native-token-stream': {}, - 'native-token-periodic': {}, - 'erc20-token-stream': {}, - 'erc20-token-periodic': {}, - other: {}, +const createEmptyGatorPermissionsMap: () => GatorPermissionsMap = () => { + return { + 'erc20-token-revocation': {}, + 'native-token-stream': {}, + 'native-token-periodic': {}, + 'erc20-token-stream': {}, + 'erc20-token-periodic': {}, + other: {}, + }; }; -/** - * Delegation framework version used to select the correct deployed enforcer - * contract addresses from `@metamask/delegation-deployments`. - */ -export const DELEGATION_FRAMEWORK_VERSION = '1.3.0'; - /** * Timeout duration for pending revocations (2 hours in milliseconds). * After this time, event listeners will be cleaned up to prevent memory leaks. @@ -162,7 +160,7 @@ export function getDefaultGatorPermissionsControllerState(): GatorPermissionsCon return { isGatorPermissionsEnabled: false, gatorPermissionsMapSerialized: serializeGatorPermissionsMap( - defaultGatorPermissionsMap, + createEmptyGatorPermissionsMap(), ), isFetchingGatorPermissions: false, gatorPermissionsProviderSnapId: defaultGatorPermissionsProviderSnapId, @@ -227,6 +225,23 @@ export type GatorPermissionsControllerAddPendingRevocationAction = { handler: GatorPermissionsController['addPendingRevocation']; }; +/** + * The action which can be used to submit a revocation directly without requiring + * an on-chain transaction (for already-disabled delegations). + */ +export type GatorPermissionsControllerSubmitDirectRevocationAction = { + type: `${typeof controllerName}:submitDirectRevocation`; + handler: GatorPermissionsController['submitDirectRevocation']; +}; + +/** + * The action which can be used to check if a permission context is pending revocation. + */ +export type GatorPermissionsControllerIsPendingRevocationAction = { + type: `${typeof controllerName}:isPendingRevocation`; + handler: GatorPermissionsController['isPendingRevocation']; +}; + /** * All actions that {@link GatorPermissionsController} registers, to be called * externally. @@ -238,7 +253,9 @@ export type GatorPermissionsControllerActions = | GatorPermissionsControllerDisableGatorPermissionsAction | GatorPermissionsControllerDecodePermissionFromPermissionContextForOriginAction | GatorPermissionsControllerSubmitRevocationAction - | GatorPermissionsControllerAddPendingRevocationAction; + | GatorPermissionsControllerAddPendingRevocationAction + | GatorPermissionsControllerSubmitDirectRevocationAction + | GatorPermissionsControllerIsPendingRevocationAction; /** * All actions that {@link GatorPermissionsController} calls internally. @@ -353,7 +370,8 @@ export default class GatorPermissionsController extends BaseController< this.update((state) => { state.pendingRevocations = state.pendingRevocations.filter( (pendingRevocations) => - pendingRevocations.permissionContext !== permissionContext, + pendingRevocations.permissionContext.toLowerCase() !== + permissionContext.toLowerCase(), ); }); } @@ -390,6 +408,16 @@ export default class GatorPermissionsController extends BaseController< `${controllerName}:addPendingRevocation`, this.addPendingRevocation.bind(this), ); + + this.messenger.registerActionHandler( + `${controllerName}:submitDirectRevocation`, + this.submitDirectRevocation.bind(this), + ); + + this.messenger.registerActionHandler( + `${controllerName}:isPendingRevocation`, + this.isPendingRevocation.bind(this), + ); } /** @@ -451,7 +479,8 @@ export default class GatorPermissionsController extends BaseController< } /** - * Sanitizes a stored gator permission by removing the fields that are not expose to MetaMask client. + * Sanitizes a stored gator permission for client exposure. + * Removes internal fields (dependencyInfo, signer) * * @param storedGatorPermission - The stored gator permission to sanitize. * @returns The sanitized stored gator permission. @@ -463,7 +492,7 @@ export default class GatorPermissionsController extends BaseController< >, ): StoredGatorPermissionSanitized { const { permissionResponse } = storedGatorPermission; - const { rules, dependencyInfo, signer, ...rest } = permissionResponse; + const { dependencyInfo, signer, ...rest } = permissionResponse; return { ...storedGatorPermission, permissionResponse: { @@ -483,63 +512,39 @@ export default class GatorPermissionsController extends BaseController< | StoredGatorPermission[] | null, ): GatorPermissionsMap { + const gatorPermissionsMap = createEmptyGatorPermissionsMap(); + if (!storedGatorPermissions) { - return defaultGatorPermissionsMap; + return gatorPermissionsMap; } - return storedGatorPermissions.reduce( - (gatorPermissionsMap, storedGatorPermission) => { - const { permissionResponse } = storedGatorPermission; - const permissionType = permissionResponse.permission.type; - const { chainId } = permissionResponse; - - const sanitizedStoredGatorPermission = - this.#sanitizeStoredGatorPermission(storedGatorPermission); - - switch (permissionType) { - case 'native-token-stream': - case 'native-token-periodic': - case 'erc20-token-stream': - case 'erc20-token-periodic': - if (!gatorPermissionsMap[permissionType][chainId]) { - gatorPermissionsMap[permissionType][chainId] = []; - } - - ( - gatorPermissionsMap[permissionType][ - chainId - ] as StoredGatorPermissionSanitized< - Signer, - PermissionTypesWithCustom - >[] - ).push(sanitizedStoredGatorPermission); - break; - default: - if (!gatorPermissionsMap.other[chainId]) { - gatorPermissionsMap.other[chainId] = []; - } - - ( - gatorPermissionsMap.other[ - chainId - ] as StoredGatorPermissionSanitized< - Signer, - PermissionTypesWithCustom - >[] - ).push(sanitizedStoredGatorPermission); - break; - } - - return gatorPermissionsMap; - }, - { - 'native-token-stream': {}, - 'native-token-periodic': {}, - 'erc20-token-stream': {}, - 'erc20-token-periodic': {}, - other: {}, - } as GatorPermissionsMap, - ); + for (const storedGatorPermission of storedGatorPermissions) { + const { + permissionResponse: { + permission: { type: permissionType }, + chainId, + }, + } = storedGatorPermission; + + const isPermissionTypeKnown = Object.prototype.hasOwnProperty.call( + gatorPermissionsMap, + permissionType, + ); + + const permissionTypeKey = isPermissionTypeKnown + ? (permissionType as keyof GatorPermissionsMap) + : 'other'; + + type PermissionsMapElementArray = + GatorPermissionsMap[typeof permissionTypeKey][typeof chainId]; + + gatorPermissionsMap[permissionTypeKey][chainId] = [ + ...(gatorPermissionsMap[permissionTypeKey][chainId] || []), + this.#sanitizeStoredGatorPermission(storedGatorPermission), + ] as PermissionsMapElementArray; + } + + return gatorPermissionsMap; } /** @@ -576,7 +581,7 @@ export default class GatorPermissionsController extends BaseController< this.update((state) => { state.isGatorPermissionsEnabled = false; state.gatorPermissionsMapSerialized = serializeGatorPermissionsMap( - defaultGatorPermissionsMap, + createEmptyGatorPermissionsMap(), ); }); } @@ -725,33 +730,50 @@ export default class GatorPermissionsController extends BaseController< this.#assertGatorPermissionsEnabled(); - try { - const snapRequest = { - snapId: this.state.gatorPermissionsProviderSnapId, - origin: 'metamask', - handler: HandlerType.OnRpcRequest, - request: { - jsonrpc: '2.0', - method: - GatorPermissionsSnapRpcMethod.PermissionProviderSubmitRevocation, - params: revocationParams, - }, - }; + const snapRequest = { + snapId: this.state.gatorPermissionsProviderSnapId, + origin: 'metamask', + handler: HandlerType.OnRpcRequest, + request: { + jsonrpc: '2.0', + method: + GatorPermissionsSnapRpcMethod.PermissionProviderSubmitRevocation, + params: revocationParams, + }, + }; + try { const result = await this.messenger.call( 'SnapController:handleRequest', snapRequest, ); - this.#removePendingRevocationFromStateByPermissionContext( - revocationParams.permissionContext, - ); + // Refresh list first (permission removed from list) + await this.fetchAndUpdateGatorPermissions({ isRevoked: false }); controllerLog('Successfully submitted revocation', { permissionContext: revocationParams.permissionContext, result, }); } catch (error) { + // If it's a GatorPermissionsFetchError, revocation succeeded but refresh failed + if (error instanceof GatorPermissionsFetchError) { + controllerLog( + 'Revocation submitted successfully but failed to refresh permissions list', + { + error, + permissionContext: revocationParams.permissionContext, + }, + ); + // Wrap with a more specific message indicating revocation succeeded + throw new GatorPermissionsFetchError({ + message: + 'Failed to refresh permissions list after successful revocation', + cause: error as Error, + }); + } + + // Otherwise, revocation failed - wrap in provider error controllerLog('Failed to submit revocation', { error, permissionContext: revocationParams.permissionContext, @@ -762,6 +784,10 @@ export default class GatorPermissionsController extends BaseController< GatorPermissionsSnapRpcMethod.PermissionProviderSubmitRevocation, cause: error as Error, }); + } finally { + this.#removePendingRevocationFromStateByPermissionContext( + revocationParams.permissionContext, + ); } } @@ -823,6 +849,19 @@ export default class GatorPermissionsController extends BaseController< timeoutId: undefined, }; + // Helper to refresh permissions after transaction state change + const refreshPermissions = (context: string) => { + this.fetchAndUpdateGatorPermissions({ isRevoked: false }).catch( + (error) => { + controllerLog(`Failed to refresh permissions after ${context}`, { + txId, + permissionContext, + error, + }); + }, + ); + }; + // Helper to unsubscribe from approval/rejection events after decision is made const cleanupApprovalHandlers = () => { if (handlers.approved) { @@ -911,16 +950,18 @@ export default class GatorPermissionsController extends BaseController< permissionContext, }); - this.submitRevocation({ permissionContext }).catch((error) => { - controllerLog( - 'Failed to submit revocation after transaction confirmed', - { - txId, - permissionContext, - error, - }, - ); - }); + this.submitRevocation({ permissionContext }) + .catch((error) => { + controllerLog( + 'Failed to submit revocation after transaction confirmed', + { + txId, + permissionContext, + error, + }, + ); + }) + .finally(() => refreshPermissions('transaction confirmed')); cleanup(transactionMeta.id); } @@ -936,6 +977,8 @@ export default class GatorPermissionsController extends BaseController< }); cleanup(payload.transactionMeta.id); + + refreshPermissions('transaction failed'); } }; @@ -948,6 +991,8 @@ export default class GatorPermissionsController extends BaseController< }); cleanup(payload.transactionMeta.id); + + refreshPermissions('transaction dropped'); } }; @@ -984,4 +1029,49 @@ export default class GatorPermissionsController extends BaseController< cleanup(txId); }, PENDING_REVOCATION_TIMEOUT); } + + /** + * Submits a revocation directly without requiring an on-chain transaction. + * Used for already-disabled delegations that don't require an on-chain transaction. + * + * This method: + * 1. Adds the permission context to pending revocations state (disables UI button) + * 2. Immediately calls submitRevocation to remove from snap storage + * 3. On success, removes from pending revocations state (re-enables UI button) + * 4. On failure, keeps in pending revocations so UI can show error/retry state + * + * @param params - The revocation parameters containing the permission context. + * @returns A promise that resolves when the revocation is submitted successfully. + * @throws {GatorPermissionsNotEnabledError} If the gator permissions are not enabled. + * @throws {GatorPermissionsProviderError} If the snap request fails. + */ + public async submitDirectRevocation(params: RevocationParams): Promise { + this.#assertGatorPermissionsEnabled(); + + // Use a placeholder txId that doesn't conflict with real transaction IDs + const placeholderTxId = `no-tx-${params.permissionContext}`; + + // Add to pending revocations state first (disables UI button immediately) + this.#addPendingRevocationToState( + placeholderTxId, + params.permissionContext, + ); + + // Immediately submit the revocation (will remove from pending on success) + await this.submitRevocation(params); + } + + /** + * Checks if a permission context is in the pending revocations list. + * + * @param permissionContext - The permission context to check. + * @returns `true` if the permission context is pending revocation, `false` otherwise. + */ + public isPendingRevocation(permissionContext: Hex): boolean { + return this.state.pendingRevocations.some( + (pendingRevocation) => + pendingRevocation.permissionContext.toLowerCase() === + permissionContext.toLowerCase(), + ); + } } diff --git a/packages/gator-permissions-controller/src/constants.ts b/packages/gator-permissions-controller/src/constants.ts new file mode 100644 index 00000000000..9dca8c093a4 --- /dev/null +++ b/packages/gator-permissions-controller/src/constants.ts @@ -0,0 +1,5 @@ +/** + * Delegation framework version used to select the correct deployed enforcer + * contract addresses from `@metamask/delegation-deployments`. + */ +export const DELEGATION_FRAMEWORK_VERSION = '1.3.0'; diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts index 7c5a0fc6263..414c50389cb 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts @@ -5,8 +5,8 @@ import { createERC20TokenPeriodTransferTerms, createTimestampTerms, ROOT_AUTHORITY, - type Hex, } from '@metamask/delegation-core'; +import type { Hex } from '@metamask/delegation-core'; import { CHAIN_ID, DELEGATOR_CONTRACTS, @@ -357,6 +357,114 @@ describe('decodePermission', () => { ).toThrow('Contract not found: TimestampEnforcer'); }); }); + + describe('erc20-token-revocation', () => { + const expectedPermissionType = 'erc20-token-revocation'; + const { AllowedCalldataEnforcer } = contracts; + + it('matches with two AllowedCalldataEnforcer and ValueLteEnforcer and NonceEnforcer', () => { + const enforcers = [ + AllowedCalldataEnforcer, + AllowedCalldataEnforcer, + ValueLteEnforcer, + NonceEnforcer, + ]; + const result = identifyPermissionByEnforcers({ enforcers, contracts }); + expect(result).toBe(expectedPermissionType); + }); + + it('allows TimestampEnforcer as extra', () => { + const enforcers = [ + AllowedCalldataEnforcer, + AllowedCalldataEnforcer, + ValueLteEnforcer, + NonceEnforcer, + TimestampEnforcer, + ]; + const result = identifyPermissionByEnforcers({ enforcers, contracts }); + expect(result).toBe(expectedPermissionType); + }); + + it('rejects when only one AllowedCalldataEnforcer is provided', () => { + const enforcers = [ + AllowedCalldataEnforcer, + ValueLteEnforcer, + NonceEnforcer, + ]; + expect(() => + identifyPermissionByEnforcers({ enforcers, contracts }), + ).toThrow('Unable to identify permission type'); + }); + + it('rejects when three AllowedCalldataEnforcer are provided', () => { + const enforcers = [ + AllowedCalldataEnforcer, + AllowedCalldataEnforcer, + AllowedCalldataEnforcer, + ValueLteEnforcer, + NonceEnforcer, + ]; + expect(() => + identifyPermissionByEnforcers({ enforcers, contracts }), + ).toThrow('Unable to identify permission type'); + }); + + it('rejects when ValueLteEnforcer is missing', () => { + const enforcers = [ + AllowedCalldataEnforcer, + AllowedCalldataEnforcer, + NonceEnforcer, + ]; + expect(() => + identifyPermissionByEnforcers({ enforcers, contracts }), + ).toThrow('Unable to identify permission type'); + }); + + it('rejects forbidden extra caveat', () => { + const enforcers = [ + AllowedCalldataEnforcer, + AllowedCalldataEnforcer, + ValueLteEnforcer, + NonceEnforcer, + // Not allowed for erc20-token-revocation + ExactCalldataEnforcer, + ]; + expect(() => + identifyPermissionByEnforcers({ enforcers, contracts }), + ).toThrow('Unable to identify permission type'); + }); + + it('accepts lowercased addresses', () => { + const enforcers: Hex[] = [ + AllowedCalldataEnforcer.toLowerCase() as unknown as Hex, + AllowedCalldataEnforcer.toLowerCase() as unknown as Hex, + ValueLteEnforcer.toLowerCase() as unknown as Hex, + NonceEnforcer.toLowerCase() as unknown as Hex, + ]; + const result = identifyPermissionByEnforcers({ enforcers, contracts }); + expect(result).toBe(expectedPermissionType); + }); + + it('throws if a contract is not found', () => { + const enforcers = [ + AllowedCalldataEnforcer, + AllowedCalldataEnforcer, + ValueLteEnforcer, + NonceEnforcer, + ]; + const contractsWithoutAllowedCalldataEnforcer = { + ...contracts, + AllowedCalldataEnforcer: undefined, + } as unknown as DeployedContractsByName; + + expect(() => + identifyPermissionByEnforcers({ + enforcers, + contracts: contractsWithoutAllowedCalldataEnforcer, + }), + ).toThrow('Contract not found: AllowedCalldataEnforcer'); + }); + }); }); describe('getPermissionDataAndExpiry', () => { @@ -484,7 +592,7 @@ describe('decodePermission', () => { caveats, permissionType, }), - ).toThrow('Invalid expiry'); + ).toThrow('Invalid expiry: timestampAfterThreshold must be 0'); }); it('rejects invalid nativeTokenStream terms', () => { @@ -505,6 +613,179 @@ describe('decodePermission', () => { }), ).toThrow('Value must be a hexadecimal string.'); }); + + it('rejects expiry terms that are too short', () => { + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: '0x1234' as Hex, + args: '0x', + } as const, + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms( + { + initialAmount, + maxAmount, + amountPerSecond, + startTime, + }, + { out: 'hex' }, + ), + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType, + }), + ).toThrow( + 'Invalid TimestampEnforcer terms length: expected 66 characters (0x + 64 hex), got 6', + ); + }); + + it('rejects expiry terms that are too long', () => { + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: `0x${'0'.repeat(68)}` as const, + args: '0x', + } as const, + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms( + { + initialAmount, + maxAmount, + amountPerSecond, + startTime, + }, + { out: 'hex' }, + ), + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType, + }), + ).toThrow( + 'Invalid TimestampEnforcer terms length: expected 66 characters (0x + 64 hex), got 70', + ); + }); + + it('rejects expiry timestamp that is not a safe integer', () => { + // Use maximum uint128 value which exceeds Number.MAX_SAFE_INTEGER + const maxUint128 = 'f'.repeat(32); + const termsHex = `0x${'0'.repeat(32)}${maxUint128}` as Hex; + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: termsHex, + args: '0x', + } as const, + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms( + { + initialAmount, + maxAmount, + amountPerSecond, + startTime, + }, + { out: 'hex' }, + ), + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType, + }), + ).toThrow('Value is not a safe integer'); + }); + + it('handles large valid expiry timestamp', () => { + // Use a large but valid timestamp (year 9999: 253402300799) + const largeTimestamp = 253402300799; + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: largeTimestamp, + }), + args: '0x', + } as const, + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms( + { + initialAmount, + maxAmount, + amountPerSecond, + startTime, + }, + { out: 'hex' }, + ), + args: '0x', + } as const, + ]; + + const { expiry, data } = getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType, + }); + + expect(expiry).toBe(largeTimestamp); + expect(hexToBigInt(data.initialAmount)).toBe(initialAmount); + }); + + it('rejects when expiry timestamp is 0', () => { + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 0, + }), + args: '0x', + } as const, + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms( + { + initialAmount, + maxAmount, + amountPerSecond, + startTime, + }, + { out: 'hex' }, + ), + args: '0x', + } as const, + ]; + + expect(() => + getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType, + }), + ).toThrow( + 'Invalid expiry: timestampBeforeThreshold must be greater than 0', + ); + }); }); describe('native-token-periodic', () => { @@ -601,7 +882,7 @@ describe('decodePermission', () => { caveats, permissionType, }), - ).toThrow('Invalid expiry'); + ).toThrow('Invalid expiry: timestampAfterThreshold must be 0'); }); it('rejects invalid nativeTokenPeriodic terms', () => { @@ -730,7 +1011,7 @@ describe('decodePermission', () => { caveats, permissionType, }), - ).toThrow('Invalid expiry'); + ).toThrow('Invalid expiry: timestampAfterThreshold must be 0'); }); it('rejects invalid erc20-token-stream terms', () => { @@ -853,7 +1134,7 @@ describe('decodePermission', () => { caveats, permissionType, }), - ).toThrow('Invalid expiry'); + ).toThrow('Invalid expiry: timestampAfterThreshold must be 0'); }); it('rejects invalid erc20-token-periodic terms', () => { @@ -875,6 +1156,23 @@ describe('decodePermission', () => { ).toThrow('Value must be a hexadecimal string.'); }); }); + + describe('erc20-token-revocation', () => { + const permissionType = 'erc20-token-revocation'; + + it('returns the correct expiry and data', () => { + const caveats = [expiryCaveat]; + + const { expiry, data } = getPermissionDataAndExpiry({ + contracts, + caveats, + permissionType, + }); + + expect(expiry).toStrictEqual(timestampBeforeThreshold); + expect(data).toStrictEqual({}); + }); + }); }); describe('reconstructDecodedPermission', () => { diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts index 882aa62baa9..2b1711b3c08 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts @@ -11,7 +11,6 @@ import { createPermissionRulesForChainId, getChecksumEnforcersByChainId, getTermsByEnforcer, - isSubset, splitHex, } from './utils'; @@ -22,7 +21,7 @@ import { * A permission type matches when: * - All of its required enforcers are present in the provided list; and * - No provided enforcer falls outside the union of the type's required and - * allowed enforcers (currently only `TimestampEnforcer` is allowed extra). + * optional enforcers (currently only `TimestampEnforcer` is allowed extra). * * If exactly one permission type matches, its identifier is returned. * @@ -40,29 +39,47 @@ export const identifyPermissionByEnforcers = ({ enforcers: Hex[]; contracts: DeployedContractsByName; }): PermissionType => { - const enforcersSet = new Set(enforcers.map(getChecksumAddress)); + // Build frequency map for enforcers (using checksummed addresses) + const counts = new Map(); + for (const addr of enforcers.map(getChecksumAddress)) { + counts.set(addr, (counts.get(addr) ?? 0) + 1); + } + const enforcersSet = new Set(counts.keys()); const permissionRules = createPermissionRulesForChainId(contracts); let matchingPermissionType: PermissionType | null = null; for (const { - allowedEnforcers, + optionalEnforcers, requiredEnforcers, permissionType, } of permissionRules) { - const hasAllRequiredEnforcers = isSubset(requiredEnforcers, enforcersSet); + // union of optional + required enforcers. Any other address is forbidden. + const allowedEnforcers = new Set([ + ...optionalEnforcers, + ...requiredEnforcers.keys(), + ]); let hasForbiddenEnforcers = false; for (const caveat of enforcersSet) { - if (!allowedEnforcers.has(caveat) && !requiredEnforcers.has(caveat)) { + if (!allowedEnforcers.has(caveat)) { hasForbiddenEnforcers = true; break; } } - if (hasAllRequiredEnforcers && !hasForbiddenEnforcers) { + // exact multiplicity match for required enforcers + let meetsRequiredCounts = true; + for (const [addr, requiredCount] of requiredEnforcers.entries()) { + if ((counts.get(addr) ?? 0) !== requiredCount) { + meetsRequiredCounts = false; + break; + } + } + + if (meetsRequiredCounts && !hasForbiddenEnforcers) { if (matchingPermissionType) { throw new Error('Multiple permission types match'); } @@ -77,6 +94,44 @@ export const identifyPermissionByEnforcers = ({ return matchingPermissionType; }; +/** + * Extracts the expiry timestamp from TimestampEnforcer caveat terms. + * + * Based on the TimestampEnforcer contract encoding: + * - Terms are 32 bytes total (64 hex characters without '0x') + * - First 16 bytes (32 hex chars): timestampAfterThreshold (uint128) - must be 0 + * - Last 16 bytes (32 hex chars): timestampBeforeThreshold (uint128) - this is the expiry + * + * @param terms - The hex-encoded terms from a TimestampEnforcer caveat + * @returns The expiry timestamp in seconds + * @throws If the terms are not exactly 32 bytes, if the timestampAfterThreshold is non-zero, + * or if the timestampBeforeThreshold is zero + */ +const extractExpiryFromCaveatTerms = (terms: Hex): number => { + // Validate terms length: must be exactly 32 bytes (64 hex chars + '0x' prefix = 66 chars) + if (terms.length !== 66) { + throw new Error( + `Invalid TimestampEnforcer terms length: expected 66 characters (0x + 64 hex), got ${terms.length}`, + ); + } + + const [after, before] = splitHex(terms, [16, 16]); + + if (hexToNumber(after) !== 0) { + throw new Error('Invalid expiry: timestampAfterThreshold must be 0'); + } + + const expiry = hexToNumber(before); + + if (expiry === 0) { + throw new Error( + 'Invalid expiry: timestampBeforeThreshold must be greater than 0', + ); + } + + return expiry; +}; + /** * Extracts the permission-specific data payload and the expiry timestamp from * the provided caveats for a given permission type. @@ -129,12 +184,7 @@ export const getPermissionDataAndExpiry = ({ let expiry: number | null = null; if (expiryTerms) { - const [after, before] = splitHex(expiryTerms, [16, 16]); - - if (hexToNumber(after) !== 0) { - throw new Error('Invalid expiry'); - } - expiry = hexToNumber(before); + expiry = extractExpiryFromCaveatTerms(expiryTerms); } let data: DecodedPermission['permission']['data']; @@ -216,6 +266,10 @@ export const getPermissionDataAndExpiry = ({ }; break; } + case 'erc20-token-revocation': { + data = {}; + break; + } default: throw new Error('Invalid permission type'); } diff --git a/packages/gator-permissions-controller/src/decodePermission/utils.test.ts b/packages/gator-permissions-controller/src/decodePermission/utils.test.ts index 9d94148245c..080bb2e1b88 100644 --- a/packages/gator-permissions-controller/src/decodePermission/utils.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/utils.test.ts @@ -1,5 +1,6 @@ import type { Caveat } from '@metamask/delegation-core'; -import { getChecksumAddress, type Hex } from '@metamask/utils'; +import { getChecksumAddress } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import type { DeployedContractsByName } from './types'; import { @@ -11,18 +12,18 @@ import { } from './utils'; // Helper to build a contracts map with lowercase addresses -const buildContracts = (): DeployedContractsByName => - ({ - ERC20PeriodTransferEnforcer: '0x1111111111111111111111111111111111111111', - ERC20StreamingEnforcer: '0x2222222222222222222222222222222222222222', - ExactCalldataEnforcer: '0x3333333333333333333333333333333333333333', - NativeTokenPeriodTransferEnforcer: - '0x4444444444444444444444444444444444444444', - NativeTokenStreamingEnforcer: '0x5555555555555555555555555555555555555555', - TimestampEnforcer: '0x6666666666666666666666666666666666666666', - ValueLteEnforcer: '0x7777777777777777777777777777777777777777', - NonceEnforcer: '0x8888888888888888888888888888888888888888', - }) as unknown as DeployedContractsByName; +const buildContracts = (): DeployedContractsByName => ({ + ERC20PeriodTransferEnforcer: '0x1111111111111111111111111111111111111111', + ERC20StreamingEnforcer: '0x2222222222222222222222222222222222222222', + ExactCalldataEnforcer: '0x3333333333333333333333333333333333333333', + NativeTokenPeriodTransferEnforcer: + '0x4444444444444444444444444444444444444444', + NativeTokenStreamingEnforcer: '0x5555555555555555555555555555555555555555', + TimestampEnforcer: '0x6666666666666666666666666666666666666666', + ValueLteEnforcer: '0x7777777777777777777777777777777777777777', + NonceEnforcer: '0x8888888888888888888888888888888888888888', + AllowedCalldataEnforcer: '0x9999999999999999999999999999999999999999', +}); describe('getChecksumEnforcersByChainId', () => { it('returns checksummed addresses for all known enforcers', () => { @@ -31,23 +32,26 @@ describe('getChecksumEnforcersByChainId', () => { expect(result).toStrictEqual({ erc20StreamingEnforcer: getChecksumAddress( - contracts.ERC20StreamingEnforcer as Hex, + contracts.ERC20StreamingEnforcer, ), erc20PeriodicEnforcer: getChecksumAddress( - contracts.ERC20PeriodTransferEnforcer as Hex, + contracts.ERC20PeriodTransferEnforcer, ), nativeTokenStreamingEnforcer: getChecksumAddress( - contracts.NativeTokenStreamingEnforcer as Hex, + contracts.NativeTokenStreamingEnforcer, ), nativeTokenPeriodicEnforcer: getChecksumAddress( - contracts.NativeTokenPeriodTransferEnforcer as Hex, + contracts.NativeTokenPeriodTransferEnforcer, ), exactCalldataEnforcer: getChecksumAddress( - contracts.ExactCalldataEnforcer as Hex, + contracts.ExactCalldataEnforcer, + ), + valueLteEnforcer: getChecksumAddress(contracts.ValueLteEnforcer), + timestampEnforcer: getChecksumAddress(contracts.TimestampEnforcer), + nonceEnforcer: getChecksumAddress(contracts.NonceEnforcer), + allowedCalldataEnforcer: getChecksumAddress( + contracts.AllowedCalldataEnforcer, ), - valueLteEnforcer: getChecksumAddress(contracts.ValueLteEnforcer as Hex), - timestampEnforcer: getChecksumAddress(contracts.TimestampEnforcer as Hex), - nonceEnforcer: getChecksumAddress(contracts.NonceEnforcer as Hex), }); }); @@ -72,10 +76,17 @@ describe('createPermissionRulesForChainId', () => { valueLteEnforcer, timestampEnforcer, nonceEnforcer, + allowedCalldataEnforcer, } = getChecksumEnforcersByChainId(contracts); + // erc20-token-stream + // erc20-token-periodic + // native-token-stream + // native-token-periodic + // erc20-token-revocation + const permissionTypeCount = 5; const rules = createPermissionRulesForChainId(contracts); - expect(rules).toHaveLength(4); + expect(rules).toHaveLength(permissionTypeCount); const byType = Object.fromEntries(rules.map((r) => [r.permissionType, r])); @@ -84,16 +95,18 @@ describe('createPermissionRulesForChainId', () => { expect(byType['native-token-stream'].permissionType).toBe( 'native-token-stream', ); - expect(byType['native-token-stream'].allowedEnforcers.size).toBe(1); + expect(byType['native-token-stream'].optionalEnforcers.size).toBe(1); expect( - byType['native-token-stream'].allowedEnforcers.has(timestampEnforcer), + byType['native-token-stream'].optionalEnforcers.has(timestampEnforcer), ).toBe(true); expect(byType['native-token-stream'].requiredEnforcers.size).toBe(3); - expect(byType['native-token-stream'].requiredEnforcers).toStrictEqual( - new Set([ - nativeTokenStreamingEnforcer, - exactCalldataEnforcer, - nonceEnforcer, + expect( + Array.from(byType['native-token-stream'].requiredEnforcers.entries()), + ).toStrictEqual( + expect.arrayContaining([ + [nativeTokenStreamingEnforcer, 1], + [exactCalldataEnforcer, 1], + [nonceEnforcer, 1], ]), ); @@ -102,16 +115,18 @@ describe('createPermissionRulesForChainId', () => { expect(byType['native-token-periodic'].permissionType).toBe( 'native-token-periodic', ); - expect(byType['native-token-periodic'].allowedEnforcers.size).toBe(1); + expect(byType['native-token-periodic'].optionalEnforcers.size).toBe(1); expect( - byType['native-token-periodic'].allowedEnforcers.has(timestampEnforcer), + byType['native-token-periodic'].optionalEnforcers.has(timestampEnforcer), ).toBe(true); expect(byType['native-token-periodic'].requiredEnforcers.size).toBe(3); - expect(byType['native-token-periodic'].requiredEnforcers).toStrictEqual( - new Set([ - nativeTokenPeriodicEnforcer, - exactCalldataEnforcer, - nonceEnforcer, + expect( + Array.from(byType['native-token-periodic'].requiredEnforcers.entries()), + ).toStrictEqual( + expect.arrayContaining([ + [nativeTokenPeriodicEnforcer, 1], + [exactCalldataEnforcer, 1], + [nonceEnforcer, 1], ]), ); @@ -120,13 +135,19 @@ describe('createPermissionRulesForChainId', () => { expect(byType['erc20-token-stream'].permissionType).toBe( 'erc20-token-stream', ); - expect(byType['erc20-token-stream'].allowedEnforcers.size).toBe(1); + expect(byType['erc20-token-stream'].optionalEnforcers.size).toBe(1); expect( - byType['erc20-token-stream'].allowedEnforcers.has(timestampEnforcer), + byType['erc20-token-stream'].optionalEnforcers.has(timestampEnforcer), ).toBe(true); expect(byType['erc20-token-stream'].requiredEnforcers.size).toBe(3); - expect(byType['erc20-token-stream'].requiredEnforcers).toStrictEqual( - new Set([erc20StreamingEnforcer, valueLteEnforcer, nonceEnforcer]), + expect( + Array.from(byType['erc20-token-stream'].requiredEnforcers.entries()), + ).toStrictEqual( + expect.arrayContaining([ + [erc20StreamingEnforcer, 1], + [valueLteEnforcer, 1], + [nonceEnforcer, 1], + ]), ); // erc20-token-periodic @@ -134,13 +155,39 @@ describe('createPermissionRulesForChainId', () => { expect(byType['erc20-token-periodic'].permissionType).toBe( 'erc20-token-periodic', ); - expect(byType['erc20-token-periodic'].allowedEnforcers.size).toBe(1); + expect(byType['erc20-token-periodic'].optionalEnforcers.size).toBe(1); expect( - byType['erc20-token-periodic'].allowedEnforcers.has(timestampEnforcer), + byType['erc20-token-periodic'].optionalEnforcers.has(timestampEnforcer), ).toBe(true); expect(byType['erc20-token-periodic'].requiredEnforcers.size).toBe(3); - expect(byType['erc20-token-periodic'].requiredEnforcers).toStrictEqual( - new Set([erc20PeriodicEnforcer, valueLteEnforcer, nonceEnforcer]), + expect( + Array.from(byType['erc20-token-periodic'].requiredEnforcers.entries()), + ).toStrictEqual( + expect.arrayContaining([ + [erc20PeriodicEnforcer, 1], + [valueLteEnforcer, 1], + [nonceEnforcer, 1], + ]), + ); + + // erc20-token-revocation + expect(byType['erc20-token-revocation']).toBeDefined(); + expect(byType['erc20-token-revocation'].permissionType).toBe( + 'erc20-token-revocation', + ); + expect(byType['erc20-token-revocation'].optionalEnforcers.size).toBe(1); + expect( + byType['erc20-token-revocation'].optionalEnforcers.has(timestampEnforcer), + ).toBe(true); + expect(byType['erc20-token-revocation'].requiredEnforcers.size).toBe(3); + expect( + Array.from(byType['erc20-token-revocation'].requiredEnforcers.entries()), + ).toStrictEqual( + expect.arrayContaining([ + [allowedCalldataEnforcer, 2], + [valueLteEnforcer, 1], + [nonceEnforcer, 1], + ]), ); }); }); diff --git a/packages/gator-permissions-controller/src/decodePermission/utils.ts b/packages/gator-permissions-controller/src/decodePermission/utils.ts index d129cab1b32..5c44d2422bb 100644 --- a/packages/gator-permissions-controller/src/decodePermission/utils.ts +++ b/packages/gator-permissions-controller/src/decodePermission/utils.ts @@ -1,5 +1,6 @@ import type { Caveat } from '@metamask/delegation-core'; -import { getChecksumAddress, type Hex } from '@metamask/utils'; +import { getChecksumAddress } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import type { DeployedContractsByName, PermissionType } from './types'; @@ -8,8 +9,8 @@ import type { DeployedContractsByName, PermissionType } from './types'; */ export type PermissionRule = { permissionType: PermissionType; - requiredEnforcers: Set; - allowedEnforcers: Set; + requiredEnforcers: Map; + optionalEnforcers: Set; }; /** @@ -24,6 +25,7 @@ const ENFORCER_CONTRACT_NAMES = { TimestampEnforcer: 'TimestampEnforcer', ValueLteEnforcer: 'ValueLteEnforcer', NonceEnforcer: 'NonceEnforcer', + AllowedCalldataEnforcer: 'AllowedCalldataEnforcer', }; /** @@ -75,6 +77,10 @@ export const getChecksumEnforcersByChainId = ( ENFORCER_CONTRACT_NAMES.NonceEnforcer, ); + const allowedCalldataEnforcer = getChecksumContractAddress( + ENFORCER_CONTRACT_NAMES.AllowedCalldataEnforcer, + ); + return { erc20StreamingEnforcer, erc20PeriodicEnforcer, @@ -84,6 +90,7 @@ export const getChecksumEnforcersByChainId = ( valueLteEnforcer, timestampEnforcer, nonceEnforcer, + allowedCalldataEnforcer, }; }; @@ -91,7 +98,7 @@ export const getChecksumEnforcersByChainId = ( * Builds the canonical set of permission matching rules for a chain. * * Each rule specifies the `permissionType`, the set of `requiredEnforcers` - * that must be present, and the set of `allowedEnforcers` that may appear in + * that must be present, and the set of `optionalEnforcers` that may appear in * addition to the required set. * * @param contracts - The deployed contracts for the chain. @@ -110,48 +117,58 @@ export const createPermissionRulesForChainId: ( valueLteEnforcer, timestampEnforcer, nonceEnforcer, + allowedCalldataEnforcer, } = getChecksumEnforcersByChainId(contracts); - // the allowed enforcers are the same for all permission types - const allowedEnforcers = new Set([timestampEnforcer]); + // the optional enforcers are the same for all permission types + const optionalEnforcers = new Set([timestampEnforcer]); const permissionRules: PermissionRule[] = [ { - requiredEnforcers: new Set([ - nativeTokenStreamingEnforcer, - exactCalldataEnforcer, - nonceEnforcer, + requiredEnforcers: new Map([ + [nativeTokenStreamingEnforcer, 1], + [exactCalldataEnforcer, 1], + [nonceEnforcer, 1], ]), - allowedEnforcers, + optionalEnforcers, permissionType: 'native-token-stream', }, { - requiredEnforcers: new Set([ - nativeTokenPeriodicEnforcer, - exactCalldataEnforcer, - nonceEnforcer, + requiredEnforcers: new Map([ + [nativeTokenPeriodicEnforcer, 1], + [exactCalldataEnforcer, 1], + [nonceEnforcer, 1], ]), - allowedEnforcers, + optionalEnforcers, permissionType: 'native-token-periodic', }, { - requiredEnforcers: new Set([ - erc20StreamingEnforcer, - valueLteEnforcer, - nonceEnforcer, + requiredEnforcers: new Map([ + [erc20StreamingEnforcer, 1], + [valueLteEnforcer, 1], + [nonceEnforcer, 1], ]), - allowedEnforcers, + optionalEnforcers, permissionType: 'erc20-token-stream', }, { - requiredEnforcers: new Set([ - erc20PeriodicEnforcer, - valueLteEnforcer, - nonceEnforcer, + requiredEnforcers: new Map([ + [erc20PeriodicEnforcer, 1], + [valueLteEnforcer, 1], + [nonceEnforcer, 1], ]), - allowedEnforcers, + optionalEnforcers, permissionType: 'erc20-token-periodic', }, + { + requiredEnforcers: new Map([ + [allowedCalldataEnforcer, 2], + [valueLteEnforcer, 1], + [nonceEnforcer, 1], + ]), + optionalEnforcers, + permissionType: 'erc20-token-revocation', + }, ]; return permissionRules; @@ -234,7 +251,7 @@ export function splitHex(value: Hex, lengths: number[]): Hex[] { const partCharLength = partLength * 2; const part = value.slice(start, start + partCharLength); start += partCharLength; - parts.push(`0x${part}` as Hex); + parts.push(`0x${part}` as const); } return parts; } diff --git a/packages/gator-permissions-controller/src/index.ts b/packages/gator-permissions-controller/src/index.ts index 6fb66704515..9b13e2500c1 100644 --- a/packages/gator-permissions-controller/src/index.ts +++ b/packages/gator-permissions-controller/src/index.ts @@ -13,11 +13,14 @@ export type { GatorPermissionsControllerDisableGatorPermissionsAction, GatorPermissionsControllerSubmitRevocationAction, GatorPermissionsControllerAddPendingRevocationAction, + GatorPermissionsControllerSubmitDirectRevocationAction, + GatorPermissionsControllerIsPendingRevocationAction, GatorPermissionsControllerActions, GatorPermissionsControllerEvents, GatorPermissionsControllerStateChangeEvent, } from './GatorPermissionsController'; export type { DecodedPermission } from './decodePermission'; +export { DELEGATION_FRAMEWORK_VERSION } from './constants'; export type { GatorPermissionsControllerErrorCode, GatorPermissionsSnapRpcMethod, diff --git a/packages/gator-permissions-controller/src/test/mock.test.ts b/packages/gator-permissions-controller/src/test/mock.test.ts index 1c5be6eff29..35e87cf0e84 100644 --- a/packages/gator-permissions-controller/src/test/mock.test.ts +++ b/packages/gator-permissions-controller/src/test/mock.test.ts @@ -1,7 +1,5 @@ -import { - mockGatorPermissionsStorageEntriesFactory, - type MockGatorPermissionsStorageEntriesConfig, -} from './mocks'; +import { mockGatorPermissionsStorageEntriesFactory } from './mocks'; +import type { MockGatorPermissionsStorageEntriesConfig } from './mocks'; describe('mockGatorPermissionsStorageEntriesFactory', () => { it('should create mock storage entries for all permission types', () => { diff --git a/packages/gator-permissions-controller/src/test/mocks.ts b/packages/gator-permissions-controller/src/test/mocks.ts index 04f03f7f36d..322ffa6f230 100644 --- a/packages/gator-permissions-controller/src/test/mocks.ts +++ b/packages/gator-permissions-controller/src/test/mocks.ts @@ -17,7 +17,7 @@ export const mockNativeTokenStreamStorageEntry = ( chainId: Hex, ): StoredGatorPermission => ({ permissionResponse: { - chainId: chainId as Hex, + chainId, address: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', signer: { type: 'account', @@ -53,7 +53,7 @@ export const mockNativeTokenPeriodicStorageEntry = ( chainId: Hex, ): StoredGatorPermission => ({ permissionResponse: { - chainId: chainId as Hex, + chainId, address: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', signer: { type: 'account', @@ -88,7 +88,7 @@ export const mockErc20TokenStreamStorageEntry = ( chainId: Hex, ): StoredGatorPermission => ({ permissionResponse: { - chainId: chainId as Hex, + chainId, address: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', signer: { type: 'account', @@ -125,7 +125,7 @@ export const mockErc20TokenPeriodicStorageEntry = ( chainId: Hex, ): StoredGatorPermission => ({ permissionResponse: { - chainId: chainId as Hex, + chainId, address: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', signer: { type: 'account', @@ -162,7 +162,7 @@ export const mockCustomPermissionStorageEntry = ( data: Record, ): StoredGatorPermission => ({ permissionResponse: { - chainId: chainId as Hex, + chainId, address: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', signer: { type: 'account', diff --git a/packages/gator-permissions-controller/src/types.ts b/packages/gator-permissions-controller/src/types.ts index b214b20935f..b1f57419e26 100644 --- a/packages/gator-permissions-controller/src/types.ts +++ b/packages/gator-permissions-controller/src/types.ts @@ -8,6 +8,7 @@ import type { Erc20TokenPeriodicPermission, Rule, MetaMaskBasePermissionData, + Erc20TokenRevocationPermission, } from '@metamask/7715-permission-types'; import type { Delegation } from '@metamask/delegation-core'; import type { Hex } from '@metamask/utils'; @@ -124,7 +125,7 @@ export type PermissionResponse< /** * Represents a sanitized version of the PermissionResponse type. - * Some fields have been removed but the fields are still present in profile sync. + * Internal fields (dependencyInfo, signer) are removed * * @template Signer - The type of the signer provided, either an AccountSigner or WalletSigner. * @template Permission - The type of the permission provided. @@ -132,10 +133,7 @@ export type PermissionResponse< export type PermissionResponseSanitized< TSigner extends Signer, TPermission extends PermissionTypesWithCustom, -> = Omit< - PermissionResponse, - 'dependencyInfo' | 'signer' | 'rules' ->; +> = Omit, 'dependencyInfo' | 'signer'>; /** * Represents a gator ERC-7715 granted(ie. signed by an user account) permission entry that is stored in profile sync. @@ -177,6 +175,12 @@ export type StoredGatorPermissionSanitized< * Represents a map of gator permissions by chainId and permission type. */ export type GatorPermissionsMap = { + 'erc20-token-revocation': { + [chainId: Hex]: StoredGatorPermissionSanitized< + Signer, + Erc20TokenRevocationPermission + >[]; + }; 'native-token-stream': { [chainId: Hex]: StoredGatorPermissionSanitized< Signer, diff --git a/packages/gator-permissions-controller/src/utils.test.ts b/packages/gator-permissions-controller/src/utils.test.ts index 81a5f732597..2d586db9f92 100644 --- a/packages/gator-permissions-controller/src/utils.test.ts +++ b/packages/gator-permissions-controller/src/utils.test.ts @@ -5,6 +5,7 @@ import { } from './utils'; const defaultGatorPermissionsMap: GatorPermissionsMap = { + 'erc20-token-revocation': {}, 'native-token-stream': {}, 'native-token-periodic': {}, 'erc20-token-stream': {}, @@ -24,8 +25,8 @@ describe('utils - serializeGatorPermissionsMap() tests', () => { }); it('throws an error when serialization fails', () => { - // Create a valid GatorPermissionsMap structure but with circular reference const gatorPermissionsMap = { + 'erc20-token-revocation': {}, 'native-token-stream': {}, 'native-token-periodic': {}, 'erc20-token-stream': {}, @@ -33,9 +34,11 @@ describe('utils - serializeGatorPermissionsMap() tests', () => { other: {}, }; - // Add circular reference to cause JSON.stringify to fail - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (gatorPermissionsMap as any).circular = gatorPermissionsMap; + // explicitly cause serialization to fail + (gatorPermissionsMap as unknown as { toJSON: () => void }).toJSON = + (): void => { + throw new Error('Failed serialization'); + }; expect(() => { serializeGatorPermissionsMap(gatorPermissionsMap); diff --git a/packages/json-rpc-engine/src/asV2Middleware.ts b/packages/json-rpc-engine/src/asV2Middleware.ts index e66cd9b02ca..a4e128ef1b2 100644 --- a/packages/json-rpc-engine/src/asV2Middleware.ts +++ b/packages/json-rpc-engine/src/asV2Middleware.ts @@ -1,10 +1,11 @@ import { serializeError } from '@metamask/rpc-errors'; -import type { JsonRpcFailure, JsonRpcResponse } from '@metamask/utils'; -import { - hasProperty, - type Json, - type JsonRpcParams, - type JsonRpcRequest, +import { hasProperty } from '@metamask/utils'; +import type { + Json, + JsonRpcFailure, + JsonRpcParams, + JsonRpcRequest, + JsonRpcResponse, } from '@metamask/utils'; import type { @@ -12,7 +13,7 @@ import type { JsonRpcEngineEndCallback, JsonRpcEngineNextCallback, } from './JsonRpcEngine'; -import { type JsonRpcMiddleware as LegacyMiddleware } from './JsonRpcEngine'; +import type { JsonRpcMiddleware as LegacyMiddleware } from './JsonRpcEngine'; import { mergeMiddleware } from './mergeMiddleware'; import type { ContextConstraint, MiddlewareContext } from './v2'; import { diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts index 1106f745827..da267a04f21 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts @@ -6,14 +6,8 @@ import type { JsonRpcMiddleware, ResultConstraint } from './JsonRpcEngineV2'; import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; import type { EmptyContext } from './MiddlewareContext'; import { MiddlewareContext } from './MiddlewareContext'; -import { - isRequest, - JsonRpcEngineError, - stringify, - type JsonRpcCall, - type JsonRpcNotification, - type JsonRpcRequest, -} from './utils'; +import { isRequest, JsonRpcEngineError, stringify } from './utils'; +import type { JsonRpcCall, JsonRpcNotification, JsonRpcRequest } from './utils'; import { makeNotification, makeNotificationMiddleware, @@ -72,7 +66,7 @@ describe('JsonRpcEngineV2', () => { // between these two cases: // - JsonRpcMiddleware | JsonRpcMiddleware (invalid) // - JsonRpcMiddleware | JsonRpcMiddleware (valid) - expect(await engine.handle(makeRequest() as JsonRpcRequest)).toBe('foo'); + expect(await engine.handle(makeRequest())).toBe('foo'); }); }); @@ -341,7 +335,7 @@ describe('JsonRpcEngineV2', () => { string | undefined, Context > = ({ context }) => { - return context.get('foo') as string | undefined; + return context.get('foo'); }; const engine = JsonRpcEngineV2.create({ middleware: [middleware1, middleware2], @@ -811,9 +805,10 @@ describe('JsonRpcEngineV2', () => { it('eagerly processes requests in parallel, i.e. without queueing them', async () => { const queue = makeArbitraryQueue(3); - const middleware: JsonRpcMiddleware< - JsonRpcRequest & { id: number } - > = async ({ request }) => { + type NumericIdRequest = JsonRpcRequest & { id: number }; + const middleware: JsonRpcMiddleware = async ({ + request, + }) => { await queue.enqueue(request.id); return null; }; @@ -821,9 +816,9 @@ describe('JsonRpcEngineV2', () => { middleware: [middleware], }); - const p0 = engine.handle(makeRequest({ id: 0 })); - const p1 = engine.handle(makeRequest({ id: 1 })); - const p2 = engine.handle(makeRequest({ id: 2 })); + const p0 = engine.handle(makeRequest({ id: 0 })); + const p1 = engine.handle(makeRequest({ id: 1 })); + const p2 = engine.handle(makeRequest({ id: 2 })); await queue.filled(); @@ -1210,7 +1205,7 @@ describe('JsonRpcEngineV2', () => { ); await expect( // @ts-expect-error - Invalid at runtime and should cause a type error - engine.handle(makeRequest() as JsonRpcRequest), + engine.handle(makeRequest()), ).rejects.toThrow( new JsonRpcEngineError( `Nothing ended request: ${stringify(makeRequest())}`, diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index 43eabd5c51b..0ce10fa123e 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -1,9 +1,9 @@ -import { - type Json, - type JsonRpcRequest, - type JsonRpcNotification, - type NonEmptyArray, - hasProperty, +import { hasProperty } from '@metamask/utils'; +import type { + Json, + JsonRpcRequest, + JsonRpcNotification, + NonEmptyArray, } from '@metamask/utils'; import deepFreeze from 'deep-freeze-strict'; diff --git a/packages/json-rpc-engine/src/v2/MiddlewareContext.ts b/packages/json-rpc-engine/src/v2/MiddlewareContext.ts index 75d64203868..66314d9c3af 100644 --- a/packages/json-rpc-engine/src/v2/MiddlewareContext.ts +++ b/packages/json-rpc-engine/src/v2/MiddlewareContext.ts @@ -1,4 +1,5 @@ -import { isInstance, type UnionToIntersection } from './utils'; +import { isInstance } from './utils'; +import type { UnionToIntersection } from './utils'; const MiddlewareContextSymbol = Symbol.for('json-rpc-engine#MiddlewareContext'); diff --git a/packages/json-rpc-engine/src/v2/compatibility-utils.ts b/packages/json-rpc-engine/src/v2/compatibility-utils.ts index cdd65cb0de4..69d32e40ffa 100644 --- a/packages/json-rpc-engine/src/v2/compatibility-utils.ts +++ b/packages/json-rpc-engine/src/v2/compatibility-utils.ts @@ -5,7 +5,8 @@ import { hasProperty, isObject } from '@metamask/utils'; import { klona } from 'klona'; import { MiddlewareContext } from './MiddlewareContext'; -import { stringify, type JsonRpcRequest } from './utils'; +import { stringify } from './utils'; +import type { JsonRpcRequest } from './utils'; // Legacy engine compatibility utils diff --git a/packages/json-rpc-engine/src/v2/utils.ts b/packages/json-rpc-engine/src/v2/utils.ts index b324034d4ad..8894554d213 100644 --- a/packages/json-rpc-engine/src/v2/utils.ts +++ b/packages/json-rpc-engine/src/v2/utils.ts @@ -1,9 +1,8 @@ -import { - hasProperty, - isObject, - type JsonRpcNotification, - type JsonRpcParams, - type JsonRpcRequest, +import { hasProperty, isObject } from '@metamask/utils'; +import type { + JsonRpcNotification, + JsonRpcParams, + JsonRpcRequest, } from '@metamask/utils'; export type { diff --git a/packages/json-rpc-engine/tests/utils.ts b/packages/json-rpc-engine/tests/utils.ts index 3372475ca12..a30f6b166f9 100644 --- a/packages/json-rpc-engine/tests/utils.ts +++ b/packages/json-rpc-engine/tests/utils.ts @@ -6,11 +6,8 @@ import type { JsonRpcNotification } from '../src/v2/utils'; const jsonrpc = '2.0' as const; -export const makeRequest = < - Input extends Partial, - Output extends Input & JsonRpcRequest, ->( - request: Input = {} as Input, +export const makeRequest = ( + request: Partial = {}, ) => ({ jsonrpc, @@ -19,7 +16,7 @@ export const makeRequest = < params: request.params === undefined ? [] : request.params, ...request, - }) as Output; + }) as Request; export const makeNotification = >( params: Request = {} as Request, diff --git a/packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts b/packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts index 38f502f819e..6dfed55897e 100644 --- a/packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts +++ b/packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts @@ -4,12 +4,12 @@ import type { JsonRpcMiddleware, } from '@metamask/json-rpc-engine'; import SafeEventEmitter from '@metamask/safe-event-emitter'; -import { - hasProperty, - type JsonRpcNotification, - type JsonRpcParams, - type JsonRpcRequest, - type PendingJsonRpcResponse, +import { hasProperty } from '@metamask/utils'; +import type { + JsonRpcNotification, + JsonRpcParams, + JsonRpcRequest, + PendingJsonRpcResponse, } from '@metamask/utils'; import { Duplex } from 'readable-stream'; diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index c4be97750fe..4c695436dbf 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added new `KeyringBuilder` type ([#7334](https://github.com/MetaMask/core/pull/7334)) +- Added an action to call `removeAccount` ([#7241](https://github.com/MetaMask/core/pull/7241)) + - This action is meant to be consumed by the `MultichainAccountService` to encapsulate the act of removing a wallet when seed phrase backup fails in the clients. + ## [25.0.0] ### Added diff --git a/packages/keyring-controller/jest.config.js b/packages/keyring-controller/jest.config.js index 972e5a3e382..21e1aaf973e 100644 --- a/packages/keyring-controller/jest.config.js +++ b/packages/keyring-controller/jest.config.js @@ -17,7 +17,7 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 95.2, + branches: 95.13, functions: 100, lines: 98.8, statements: 98.81, diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index cfd586554aa..220f315d899 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -14,15 +14,15 @@ import { import SimpleKeyring from '@metamask/eth-simple-keyring'; import type { EthKeyring } from '@metamask/keyring-internal-api'; import type { KeyringClass } from '@metamask/keyring-utils'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { wordlist } from '@metamask/scure-bip39/dist/wordlists/english'; -import { bytesToHex, isValidHexAddress, type Hex } from '@metamask/utils'; +import { bytesToHex, isValidHexAddress } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import sinon from 'sinon'; import { KeyringControllerError } from './constants'; diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index b4dce9f1354..590a10552fc 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -13,7 +13,7 @@ import type { EthUserOperationPatch, } from '@metamask/keyring-api'; import type { EthKeyring } from '@metamask/keyring-internal-api'; -import type { KeyringClass } from '@metamask/keyring-utils'; +import type { Keyring, KeyringClass } from '@metamask/keyring-utils'; import type { Messenger } from '@metamask/messenger'; import type { Eip1024EncryptedData, Hex, Json } from '@metamask/utils'; import { @@ -199,6 +199,11 @@ export type KeyringControllerAddNewKeyringAction = { handler: KeyringController['addNewKeyring']; }; +export type KeyringControllerRemoveAccountAction = { + type: `${typeof name}:removeAccount`; + handler: KeyringController['removeAccount']; +}; + export type KeyringControllerStateChangeEvent = { type: `${typeof name}:stateChange`; payload: [KeyringControllerState, Patch[]]; @@ -238,7 +243,8 @@ export type KeyringControllerActions = | KeyringControllerWithKeyringAction | KeyringControllerAddNewKeyringAction | KeyringControllerCreateNewVaultAndKeychainAction - | KeyringControllerCreateNewVaultAndRestoreAction; + | KeyringControllerCreateNewVaultAndRestoreAction + | KeyringControllerRemoveAccountAction; export type KeyringControllerEvents = | KeyringControllerStateChangeEvent @@ -485,6 +491,9 @@ export type Encryptor< generateSalt: typeof encryptorUtils.generateSalt; }; +/** + * Keyring selector used for `withKeyring`. + */ export type KeyringSelector = | { type: string; @@ -497,6 +506,14 @@ export type KeyringSelector = id: string; }; +/** + * Keyring builder. + */ +export type KeyringBuilder = { + (): Keyring; + type: string; +}; + /** * A function executed within a mutually exclusive lock, with * a mutex releaser in its option bag. @@ -517,8 +534,10 @@ type MutuallyExclusiveCallback = ({ * @param KeyringConstructor - The Keyring class for the builder. * @returns A builder function for the given Keyring. */ -export function keyringBuilderFactory(KeyringConstructor: KeyringClass) { - const builder = () => new KeyringConstructor(); +export function keyringBuilderFactory( + KeyringConstructor: KeyringClass, +): KeyringBuilder { + const builder: KeyringBuilder = (): Keyring => new KeyringConstructor(); builder.type = KeyringConstructor.type; @@ -569,7 +588,7 @@ function assertIsValidPassword(password: unknown): asserts password is string { throw new Error(KeyringControllerError.WrongPasswordType); } - if (!password || !password.length) { + if (!password?.length) { throw new Error(KeyringControllerError.InvalidEmptyPassword); } } @@ -889,7 +908,7 @@ export class KeyringController< * @param password - Password to unlock the new vault. * @returns Promise resolving when the operation ends successfully. */ - async createNewVaultAndKeychain(password: string) { + async createNewVaultAndKeychain(password: string): Promise { return this.#persistOrRollback(async () => { const accounts = await this.#getAccountsFromKeyrings(); if (!accounts.length) { @@ -925,7 +944,7 @@ export class KeyringController< * * @param password - Password of the keyring. */ - async verifyPassword(password: string) { + async verifyPassword(password: string): Promise { if (!this.state.vault) { throw new Error(KeyringControllerError.VaultError); } @@ -1054,7 +1073,7 @@ export class KeyringController< const candidates = await Promise.all( this.#keyrings.map(async ({ keyring }) => { - return Promise.all([keyring, keyring.getAccounts()]); + return [keyring, await keyring.getAccounts()] as const; }), ); @@ -1129,7 +1148,7 @@ export class KeyringController< return this.#persistOrRollback(async () => { let privateKey; switch (strategy) { - case AccountImportStrategy.privateKey: + case AccountImportStrategy.privateKey: { const [importedKey] = args; if (!importedKey) { throw new Error('Cannot import an empty key.'); @@ -1153,22 +1172,24 @@ export class KeyringController< privateKey = remove0x(prefixed); break; - case AccountImportStrategy.json: + } + case AccountImportStrategy.json: { let wallet; const [input, password] = args; try { wallet = importers.fromEtherWallet(input, password); - } catch (e) { - wallet = wallet || (await Wallet.fromV3(input, password, true)); + } catch { + wallet = wallet ?? (await Wallet.fromV3(input, password, true)); } - privateKey = bytesToHex(wallet.getPrivateKey()); + privateKey = bytesToHex(new Uint8Array(wallet.getPrivateKey())); break; + } default: throw new Error(`Unexpected import strategy: '${String(strategy)}'`); } - const newKeyring = (await this.#newKeyring(KeyringTypes.simple, [ + const newKeyring = await this.#newKeyring(KeyringTypes.simple, [ privateKey, - ])) as EthKeyring; + ]); const accounts = await newKeyring.getAccounts(); return accounts[0]; }); @@ -1308,7 +1329,9 @@ export class KeyringController< * @param messageParams - PersonalMessageParams object to sign. * @returns Promise resolving to a signed message string. */ - async signPersonalMessage(messageParams: PersonalMessageParams) { + async signPersonalMessage( + messageParams: PersonalMessageParams, + ): Promise { this.#assertIsUnlocked(); const address = ethNormalize(messageParams.from) as Hex; const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; @@ -1681,7 +1704,7 @@ export class KeyringController< | SelectedKeyring | undefined; } else if ('type' in selector) { - keyring = this.getKeyringsByType(selector.type)[selector.index || 0] as + keyring = this.getKeyringsByType(selector.type)[selector.index ?? 0] as | SelectedKeyring | undefined; @@ -1727,7 +1750,7 @@ export class KeyringController< * Constructor helper for registering this controller's messeger * actions. */ - #registerMessageHandlers() { + #registerMessageHandlers(): void { this.messenger.registerActionHandler( `${name}:signMessage`, this.signMessage.bind(this), @@ -1817,6 +1840,11 @@ export class KeyringController< `${name}:createNewVaultAndRestore`, this.createNewVaultAndRestore.bind(this), ); + + this.messenger.registerActionHandler( + `${name}:removeAccount`, + this.removeAccount.bind(this), + ); } /** @@ -2166,7 +2194,7 @@ export class KeyringController< } else { this.#setEncryptionKey( credentials.encryptionKey, - credentials.encryptionSalt || parsedEncryptedVault.salt, + credentials.encryptionSalt ?? parsedEncryptedVault.salt, ); } @@ -2303,10 +2331,13 @@ export class KeyringController< * @param opts - Optional parameters required to instantiate the keyring. * @returns A promise that resolves if the operation is successful. */ - async #createKeyringWithFirstAccount(type: string, opts?: unknown) { + async #createKeyringWithFirstAccount( + type: string, + opts?: unknown, + ): Promise { this.#assertControllerMutexIsLocked(); - const keyring = (await this.#newKeyring(type, opts)) as EthKeyring; + const keyring = await this.#newKeyring(type, opts); const [firstAccount] = await keyring.getAccounts(); if (!firstAccount) { @@ -2395,7 +2426,7 @@ export class KeyringController< * Remove all managed keyrings, destroying all their * instances in memory. */ - async #clearKeyrings() { + async #clearKeyrings(): Promise { this.#assertControllerMutexIsLocked(); for (const { keyring } of this.#keyrings) { await this.#destroyKeyring(keyring); @@ -2454,7 +2485,7 @@ export class KeyringController< * * @param keyring - The keyring to destroy. */ - async #destroyKeyring(keyring: EthKeyring) { + async #destroyKeyring(keyring: EthKeyring): Promise { await keyring.destroy?.(); } @@ -2569,12 +2600,12 @@ export class KeyringController< try { return await callback({ releaseLock }); - } catch (e) { + } catch (error) { // Keyrings and encryption credentials are restored to their previous state this.#encryptionKey = currentEncryptionKey; await this.#restoreSerializedKeyrings(currentSerializedKeyrings); - throw e; + throw error; } }); } @@ -2584,7 +2615,7 @@ export class KeyringController< * * @throws If the controller mutex is not locked. */ - #assertControllerMutexIsLocked() { + #assertControllerMutexIsLocked(): void { if (!this.#controllerOperationMutex.isLocked()) { throw new Error(KeyringControllerError.ControllerLockRequired); } diff --git a/packages/keyring-controller/tests/mocks/mockEncryptor.ts b/packages/keyring-controller/tests/mocks/mockEncryptor.ts index 02d167bc081..4072b5b0650 100644 --- a/packages/keyring-controller/tests/mocks/mockEncryptor.ts +++ b/packages/keyring-controller/tests/mocks/mockEncryptor.ts @@ -1,5 +1,4 @@ // Omitting jsdoc because mock is only internal and simple enough. -/* eslint-disable jsdoc/require-jsdoc */ import type { DetailedDecryptResult, diff --git a/packages/keyring-controller/tests/mocks/mockTransaction.ts b/packages/keyring-controller/tests/mocks/mockTransaction.ts index e03869bbd2f..70e5dd1d434 100644 --- a/packages/keyring-controller/tests/mocks/mockTransaction.ts +++ b/packages/keyring-controller/tests/mocks/mockTransaction.ts @@ -1,4 +1,5 @@ -import { TransactionFactory, type TypedTxData } from '@ethereumjs/tx'; +import { TransactionFactory } from '@ethereumjs/tx'; +import type { TypedTxData } from '@ethereumjs/tx'; /** * Build a mock transaction, optionally overriding diff --git a/packages/logging-controller/src/LoggingController.test.ts b/packages/logging-controller/src/LoggingController.test.ts index 0a65f2b57da..43e3db71f1d 100644 --- a/packages/logging-controller/src/LoggingController.test.ts +++ b/packages/logging-controller/src/LoggingController.test.ts @@ -1,10 +1,9 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import * as uuid from 'uuid'; diff --git a/packages/message-manager/src/AbstractMessageManager.test.ts b/packages/message-manager/src/AbstractMessageManager.test.ts index 36d178bcb65..3801f27d306 100644 --- a/packages/message-manager/src/AbstractMessageManager.test.ts +++ b/packages/message-manager/src/AbstractMessageManager.test.ts @@ -1,18 +1,18 @@ -import { - deriveStateFromMetadata, - type ControllerGetStateAction, - type ControllerStateChangeEvent, +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; import { ApprovalType } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; -import { - AbstractMessageManager, - type AbstractMessage, - type AbstractMessageParams, - type MessageManagerState, - type MessageRequest, - type SecurityProviderRequest, +import { AbstractMessageManager } from './AbstractMessageManager'; +import type { + AbstractMessage, + AbstractMessageParams, + MessageManagerState, + MessageRequest, + SecurityProviderRequest, } from './AbstractMessageManager'; type ConcreteMessage = AbstractMessage & { diff --git a/packages/message-manager/src/AbstractMessageManager.ts b/packages/message-manager/src/AbstractMessageManager.ts index 38ed9acd424..eda638e379e 100644 --- a/packages/message-manager/src/AbstractMessageManager.ts +++ b/packages/message-manager/src/AbstractMessageManager.ts @@ -1,7 +1,7 @@ -import { - BaseController, - type ControllerStateChangeEvent, - type ControllerGetStateAction, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerStateChangeEvent, + ControllerGetStateAction, } from '@metamask/base-controller'; import type { ApprovalType } from '@metamask/controller-utils'; import type { @@ -299,10 +299,7 @@ export abstract class AbstractMessageManager< status === 'errored' || this.additionalFinishStatuses.includes(status) ) { - this.internalEvents.emit( - `${messageId as string}:finished`, - updatedMessage, - ); + this.internalEvents.emit(`${messageId}:finished`, updatedMessage); } } diff --git a/packages/messenger/src/Messenger.test.ts b/packages/messenger/src/Messenger.test.ts index 5e59910340c..632616f69a6 100644 --- a/packages/messenger/src/Messenger.test.ts +++ b/packages/messenger/src/Messenger.test.ts @@ -1,11 +1,8 @@ import type { Patch } from 'immer'; import sinon from 'sinon'; -import { - type MockAnyNamespace, - Messenger, - MOCK_ANY_NAMESPACE, -} from './Messenger'; +import { Messenger, MOCK_ANY_NAMESPACE } from './Messenger'; +import type { MockAnyNamespace } from './Messenger'; describe('Messenger', () => { afterEach(() => { diff --git a/packages/messenger/src/Messenger.ts b/packages/messenger/src/Messenger.ts index 097b063f036..4396d594d96 100644 --- a/packages/messenger/src/Messenger.ts +++ b/packages/messenger/src/Messenger.ts @@ -881,7 +881,7 @@ export class Messenger< } for (const actionType of actions || []) { const delegationTargets = this.#actionDelegationTargets.get(actionType); - if (!delegationTargets || !delegationTargets.has(messenger)) { + if (!delegationTargets?.has(messenger)) { // Nothing to revoke continue; } diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index 50e1829670e..c11bf2d68bf 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/accounts-controller` (^35.0.0) + - `@metamask/error-reporting-service` (^3.0.0) + - `@metamask/keyring-controller` (^25.0.0) + - `@metamask/snaps-controllers` (^14.0.1) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [4.0.0] ### Changed diff --git a/packages/multichain-account-service/package.json b/packages/multichain-account-service/package.json index e902a1fd1e8..e4f73a19700 100644 --- a/packages/multichain-account-service/package.json +++ b/packages/multichain-account-service/package.json @@ -49,14 +49,18 @@ }, "dependencies": { "@ethereumjs/util": "^9.1.0", + "@metamask/accounts-controller": "^35.0.0", "@metamask/base-controller": "^9.0.0", + "@metamask/error-reporting-service": "^3.0.0", "@metamask/eth-snap-keyring": "^18.0.0", "@metamask/key-tree": "^10.1.1", "@metamask/keyring-api": "^21.0.0", + "@metamask/keyring-controller": "^25.0.0", "@metamask/keyring-internal-api": "^9.0.0", "@metamask/keyring-snap-client": "^8.0.0", "@metamask/keyring-utils": "^3.1.0", "@metamask/messenger": "^0.3.0", + "@metamask/snaps-controllers": "^14.0.1", "@metamask/snaps-sdk": "^9.0.0", "@metamask/snaps-utils": "^11.0.0", "@metamask/superstruct": "^3.1.0", @@ -65,13 +69,9 @@ }, "devDependencies": { "@metamask/account-api": "^0.12.0", - "@metamask/accounts-controller": "^35.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/error-reporting-service": "^3.0.0", "@metamask/eth-hd-keyring": "^13.0.0", - "@metamask/keyring-controller": "^25.0.0", "@metamask/providers": "^22.1.0", - "@metamask/snaps-controllers": "^14.0.1", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "@types/uuid": "^8.3.0", @@ -86,11 +86,7 @@ }, "peerDependencies": { "@metamask/account-api": "^0.12.0", - "@metamask/accounts-controller": "^35.0.0", - "@metamask/error-reporting-service": "^3.0.0", - "@metamask/keyring-controller": "^25.0.0", "@metamask/providers": "^22.0.0", - "@metamask/snaps-controllers": "^14.0.0", "webextension-polyfill": "^0.10.0 || ^0.11.0 || ^0.12.0" }, "engines": { diff --git a/packages/multichain-account-service/src/MultichainAccountGroup.test.ts b/packages/multichain-account-service/src/MultichainAccountGroup.test.ts index 7777b72e7d3..a41e791b809 100644 --- a/packages/multichain-account-service/src/MultichainAccountGroup.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountGroup.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable jsdoc/require-jsdoc */ import type { Bip44Account } from '@metamask/account-api'; import { AccountGroupType, @@ -10,7 +9,6 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import { MultichainAccountGroup } from './MultichainAccountGroup'; import { MultichainAccountWallet } from './MultichainAccountWallet'; -import type { MockAccountProvider } from './tests'; import { MOCK_SNAP_ACCOUNT_2, MOCK_WALLET_1_BTC_P2TR_ACCOUNT, @@ -21,8 +19,8 @@ import { setupNamedAccountProvider, getMultichainAccountServiceMessenger, getRootMessenger, - type RootMessenger, } from './tests'; +import type { MockAccountProvider, RootMessenger } from './tests'; import type { MultichainAccountServiceMessenger } from './types'; function setup({ diff --git a/packages/multichain-account-service/src/MultichainAccountGroup.ts b/packages/multichain-account-service/src/MultichainAccountGroup.ts index bc3163a1f67..7440c366935 100644 --- a/packages/multichain-account-service/src/MultichainAccountGroup.ts +++ b/packages/multichain-account-service/src/MultichainAccountGroup.ts @@ -1,12 +1,12 @@ import { AccountGroupType, select, selectOne } from '@metamask/account-api'; -import { - toMultichainAccountGroupId, - type MultichainAccountGroupId, - type MultichainAccountGroup as MultichainAccountGroupDefinition, +import { toMultichainAccountGroupId } from '@metamask/account-api'; +import type { + MultichainAccountGroupId, + MultichainAccountGroup as MultichainAccountGroupDefinition, } from '@metamask/account-api'; import type { Bip44Account } from '@metamask/account-api'; import type { AccountSelector } from '@metamask/account-api'; -import { type KeyringAccount } from '@metamask/keyring-api'; +import type { KeyringAccount } from '@metamask/keyring-api'; import type { Logger } from './logger'; import { diff --git a/packages/multichain-account-service/src/MultichainAccountService.test.ts b/packages/multichain-account-service/src/MultichainAccountService.test.ts index 9894408b3ca..e8aa118be69 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.test.ts @@ -1,10 +1,9 @@ -/* eslint-disable jsdoc/require-jsdoc */ - import { isBip44Account } from '@metamask/account-api'; import { mnemonicPhraseToBytes } from '@metamask/key-tree'; import type { KeyringAccount } from '@metamask/keyring-api'; import { EthAccountType, SolAccountType } from '@metamask/keyring-api'; -import { KeyringTypes, type KeyringObject } from '@metamask/keyring-controller'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { KeyringObject } from '@metamask/keyring-controller'; import type { MultichainAccountServiceOptions } from './MultichainAccountService'; import { MultichainAccountService } from './MultichainAccountService'; @@ -18,7 +17,6 @@ import { SOL_ACCOUNT_PROVIDER_NAME, SolAccountProvider, } from './providers/SolAccountProvider'; -import type { MockAccountProvider } from './tests'; import { MOCK_HARDWARE_ACCOUNT_1, MOCK_HD_ACCOUNT_1, @@ -37,8 +35,8 @@ import { makeMockAccountProvider, mockAsInternalAccount, setupNamedAccountProvider, - type RootMessenger, } from './tests'; +import type { MockAccountProvider, RootMessenger } from './tests'; import type { MultichainAccountServiceMessenger } from './types'; // Mock providers. diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index d779736c75a..cc8a9d7f23a 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -25,10 +25,8 @@ import { isAccountProviderWrapper, } from './providers/AccountProviderWrapper'; import { EvmAccountProvider } from './providers/EvmAccountProvider'; -import { - SolAccountProvider, - type SolAccountProviderConfig, -} from './providers/SolAccountProvider'; +import { SolAccountProvider } from './providers/SolAccountProvider'; +import type { SolAccountProviderConfig } from './providers/SolAccountProvider'; import type { MultichainAccountServiceConfig, MultichainAccountServiceMessenger, diff --git a/packages/multichain-account-service/src/MultichainAccountWallet.test.ts b/packages/multichain-account-service/src/MultichainAccountWallet.test.ts index 38db8426ae3..5de15bb1cb3 100644 --- a/packages/multichain-account-service/src/MultichainAccountWallet.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountWallet.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable jsdoc/require-jsdoc */ import type { Bip44Account } from '@metamask/account-api'; import { AccountWalletType, @@ -7,15 +6,11 @@ import { toMultichainAccountGroupId, toMultichainAccountWalletId, } from '@metamask/account-api'; -import { - EthAccountType, - SolAccountType, - type EntropySourceId, -} from '@metamask/keyring-api'; +import { EthAccountType, SolAccountType } from '@metamask/keyring-api'; +import type { EntropySourceId } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { MultichainAccountWallet } from './MultichainAccountWallet'; -import type { MockAccountProvider } from './tests'; import { MOCK_HD_ACCOUNT_1, MOCK_HD_KEYRING_1, @@ -30,8 +25,8 @@ import { setupNamedAccountProvider, getMultichainAccountServiceMessenger, getRootMessenger, - type RootMessenger, } from './tests'; +import type { MockAccountProvider, RootMessenger } from './tests'; import type { MultichainAccountServiceMessenger } from './types'; function setup({ diff --git a/packages/multichain-account-service/src/MultichainAccountWallet.ts b/packages/multichain-account-service/src/MultichainAccountWallet.ts index 045138d51a9..af5a4dffb58 100644 --- a/packages/multichain-account-service/src/MultichainAccountWallet.ts +++ b/packages/multichain-account-service/src/MultichainAccountWallet.ts @@ -24,7 +24,8 @@ import { WARNING_PREFIX, } from './logger'; import { MultichainAccountGroup } from './MultichainAccountGroup'; -import { EvmAccountProvider, type Bip44AccountProvider } from './providers'; +import { EvmAccountProvider } from './providers'; +import type { Bip44AccountProvider } from './providers'; import type { MultichainAccountServiceMessenger } from './types'; import { createSentryError, toRejectedErrorMessage } from './utils'; diff --git a/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts b/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts index 67eddd59709..91244e54a1f 100644 --- a/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts +++ b/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts @@ -1,12 +1,9 @@ -import { - isBip44Account, - type AccountProvider, - type Bip44Account, -} from '@metamask/account-api'; +import { isBip44Account } from '@metamask/account-api'; +import type { AccountProvider, Bip44Account } from '@metamask/account-api'; import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; -import { - type KeyringMetadata, - type KeyringSelector, +import type { + KeyringMetadata, + KeyringSelector, } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; diff --git a/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts b/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts index d61b7362146..6cd384c242d 100644 --- a/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts @@ -22,8 +22,8 @@ import { MOCK_HD_ACCOUNT_1, MOCK_HD_KEYRING_1, MockAccountBuilder, - type RootMessenger, } from '../tests'; +import type { RootMessenger } from '../tests'; class MockBtcKeyring { readonly type = 'MockBtcKeyring'; diff --git a/packages/multichain-account-service/src/providers/BtcAccountProvider.ts b/packages/multichain-account-service/src/providers/BtcAccountProvider.ts index 1065737e64d..b4700fa5ec2 100644 --- a/packages/multichain-account-service/src/providers/BtcAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/BtcAccountProvider.ts @@ -1,14 +1,13 @@ -import { assertIsBip44Account, type Bip44Account } from '@metamask/account-api'; +import { assertIsBip44Account } from '@metamask/account-api'; +import type { Bip44Account } from '@metamask/account-api'; import type { TraceCallback } from '@metamask/controller-utils'; import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { SnapId } from '@metamask/snaps-sdk'; -import { - SnapAccountProvider, - type SnapAccountProviderConfig, -} from './SnapAccountProvider'; +import { SnapAccountProvider } from './SnapAccountProvider'; +import type { SnapAccountProviderConfig } from './SnapAccountProvider'; import { withRetry, withTimeout } from './utils'; import { traceFallback } from '../analytics'; import { TraceName } from '../constants/traces'; diff --git a/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts b/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts index ca7922c89f4..576180d2a31 100644 --- a/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts @@ -1,6 +1,5 @@ -/* eslint-disable jsdoc/require-jsdoc */ import { publicToAddress } from '@ethereumjs/util'; -import { type KeyringMetadata } from '@metamask/keyring-controller'; +import type { KeyringMetadata } from '@metamask/keyring-controller'; import type { EthKeyring, InternalAccount, @@ -25,8 +24,8 @@ import { MOCK_HD_ACCOUNT_2, MOCK_HD_KEYRING_1, MockAccountBuilder, - type RootMessenger, } from '../tests'; +import type { RootMessenger } from '../tests'; jest.mock('@ethereumjs/util', () => ({ publicToAddress: jest.fn(), diff --git a/packages/multichain-account-service/src/providers/EvmAccountProvider.ts b/packages/multichain-account-service/src/providers/EvmAccountProvider.ts index 7837ed0d387..313629174ec 100644 --- a/packages/multichain-account-service/src/providers/EvmAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/EvmAccountProvider.ts @@ -10,7 +10,8 @@ import type { InternalAccount, } from '@metamask/keyring-internal-api'; import type { Provider } from '@metamask/network-controller'; -import { add0x, assert, bytesToHex, type Hex } from '@metamask/utils'; +import { add0x, assert, bytesToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { assertAreBip44Accounts, diff --git a/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts b/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts index c47beeaee8c..9ed396b872e 100644 --- a/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts @@ -1,4 +1,5 @@ -import { isBip44Account, type Bip44Account } from '@metamask/account-api'; +import { isBip44Account } from '@metamask/account-api'; +import type { Bip44Account } from '@metamask/account-api'; import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; diff --git a/packages/multichain-account-service/src/providers/SnapAccountProvider.ts b/packages/multichain-account-service/src/providers/SnapAccountProvider.ts index 28b0ac97fd3..3e1efc548d0 100644 --- a/packages/multichain-account-service/src/providers/SnapAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/SnapAccountProvider.ts @@ -1,4 +1,4 @@ -import { type Bip44Account } from '@metamask/account-api'; +import type { Bip44Account } from '@metamask/account-api'; import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; import type { SnapKeyring } from '@metamask/eth-snap-keyring'; import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; diff --git a/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts b/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts index 68645fa31d9..5afd072d5e7 100644 --- a/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts @@ -20,8 +20,8 @@ import { MOCK_SOL_ACCOUNT_1, MOCK_SOL_DISCOVERED_ACCOUNT_1, MockAccountBuilder, - type RootMessenger, } from '../tests'; +import type { RootMessenger } from '../tests'; class MockSolanaKeyring { readonly type = 'MockSolanaKeyring'; diff --git a/packages/multichain-account-service/src/providers/SolAccountProvider.ts b/packages/multichain-account-service/src/providers/SolAccountProvider.ts index 98cf2db6d5a..a6ef998bdb4 100644 --- a/packages/multichain-account-service/src/providers/SolAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/SolAccountProvider.ts @@ -1,4 +1,5 @@ -import { assertIsBip44Account, type Bip44Account } from '@metamask/account-api'; +import { assertIsBip44Account } from '@metamask/account-api'; +import type { Bip44Account } from '@metamask/account-api'; import type { TraceCallback } from '@metamask/controller-utils'; import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { SolScope } from '@metamask/keyring-api'; @@ -10,10 +11,8 @@ import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { SnapId } from '@metamask/snaps-sdk'; -import { - SnapAccountProvider, - type SnapAccountProviderConfig, -} from './SnapAccountProvider'; +import { SnapAccountProvider } from './SnapAccountProvider'; +import type { SnapAccountProviderConfig } from './SnapAccountProvider'; import { withRetry, withTimeout } from './utils'; import { traceFallback } from '../analytics'; import { TraceName } from '../constants/traces'; diff --git a/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts b/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts index fc0ca2e8c73..26f4fd104ec 100644 --- a/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts @@ -20,8 +20,8 @@ import { MOCK_TRX_ACCOUNT_1, MOCK_TRX_DISCOVERED_ACCOUNT_1, MockAccountBuilder, - type RootMessenger, } from '../tests'; +import type { RootMessenger } from '../tests'; class MockTronKeyring { readonly type = 'MockTronKeyring'; @@ -143,7 +143,7 @@ function setup({ handleRequest: mockHandleRequest, keyring: { createAccount: keyring.createAccount as jest.Mock, - discoverAccounts: keyring.discoverAccounts as jest.Mock, + discoverAccounts: keyring.discoverAccounts, }, }, }; diff --git a/packages/multichain-account-service/src/providers/TrxAccountProvider.ts b/packages/multichain-account-service/src/providers/TrxAccountProvider.ts index f436e47ce84..256f24720b9 100644 --- a/packages/multichain-account-service/src/providers/TrxAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/TrxAccountProvider.ts @@ -1,4 +1,5 @@ -import { assertIsBip44Account, type Bip44Account } from '@metamask/account-api'; +import { assertIsBip44Account } from '@metamask/account-api'; +import type { Bip44Account } from '@metamask/account-api'; import type { TraceCallback } from '@metamask/controller-utils'; import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { TrxAccountType, TrxScope } from '@metamask/keyring-api'; @@ -6,10 +7,8 @@ import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { SnapId } from '@metamask/snaps-sdk'; -import { - SnapAccountProvider, - type SnapAccountProviderConfig, -} from './SnapAccountProvider'; +import { SnapAccountProvider } from './SnapAccountProvider'; +import type { SnapAccountProviderConfig } from './SnapAccountProvider'; import { withRetry, withTimeout } from './utils'; import { traceFallback } from '../analytics'; import { TraceName } from '../constants/traces'; diff --git a/packages/multichain-account-service/src/tests/accounts.ts b/packages/multichain-account-service/src/tests/accounts.ts index 3518196f8f1..c7d6238b263 100644 --- a/packages/multichain-account-service/src/tests/accounts.ts +++ b/packages/multichain-account-service/src/tests/accounts.ts @@ -1,4 +1,3 @@ -/* eslint-disable jsdoc/require-jsdoc */ import type { Bip44Account } from '@metamask/account-api'; import { isBip44Account } from '@metamask/account-api'; import type { diff --git a/packages/multichain-account-service/src/tests/messenger.ts b/packages/multichain-account-service/src/tests/messenger.ts index a6150212283..0999919de6a 100644 --- a/packages/multichain-account-service/src/tests/messenger.ts +++ b/packages/multichain-account-service/src/tests/messenger.ts @@ -1,9 +1,8 @@ -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { MultichainAccountServiceMessenger } from '../types'; diff --git a/packages/multichain-account-service/src/tests/providers.ts b/packages/multichain-account-service/src/tests/providers.ts index 6c204ad8759..05e1840645c 100644 --- a/packages/multichain-account-service/src/tests/providers.ts +++ b/packages/multichain-account-service/src/tests/providers.ts @@ -1,5 +1,3 @@ -/* eslint-disable jsdoc/require-jsdoc */ - import type { Bip44Account } from '@metamask/account-api'; import { isBip44Account } from '@metamask/account-api'; import type { KeyringAccount } from '@metamask/keyring-api'; diff --git a/packages/multichain-api-middleware/CHANGELOG.md b/packages/multichain-api-middleware/CHANGELOG.md index 2ce9e569e09..4d7a98b20a9 100644 --- a/packages/multichain-api-middleware/CHANGELOG.md +++ b/packages/multichain-api-middleware/CHANGELOG.md @@ -7,12 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.5] + ### Changed - Bump `@metamask/permission-controller` from `^12.1.0` to `^12.1.1` ([#7202](https://github.com/MetaMask/core/pull/7202)) -- Bump `@metamask/network-controller` from `^25.0.0` to `^26.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/network-controller` from `^26.0.0` to `^27.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202), [#7258](https://github.com/MetaMask/core/pull/7258)) - Bump `@metamask/json-rpc-engine` from `^10.1.1` to `^10.2.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) - Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/chain-agnostic-permission` from `^1.2.2` to `^1.3.0` ([#7322](https://github.com/MetaMask/core/pull/7322)) ## [1.2.4] @@ -133,7 +136,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@1.2.4...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@1.2.5...HEAD +[1.2.5]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@1.2.4...@metamask/multichain-api-middleware@1.2.5 [1.2.4]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@1.2.3...@metamask/multichain-api-middleware@1.2.4 [1.2.3]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@1.2.2...@metamask/multichain-api-middleware@1.2.3 [1.2.2]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@1.2.1...@metamask/multichain-api-middleware@1.2.2 diff --git a/packages/multichain-api-middleware/package.json b/packages/multichain-api-middleware/package.json index f18708fcce6..41a3f4ca7c2 100644 --- a/packages/multichain-api-middleware/package.json +++ b/packages/multichain-api-middleware/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/multichain-api-middleware", - "version": "1.2.4", + "version": "1.2.5", "description": "JSON-RPC methods and middleware to support the MetaMask Multichain API", "keywords": [ "MetaMask", @@ -49,10 +49,10 @@ }, "dependencies": { "@metamask/api-specs": "^0.14.0", - "@metamask/chain-agnostic-permission": "^1.2.2", + "@metamask/chain-agnostic-permission": "^1.3.0", "@metamask/controller-utils": "^11.16.0", "@metamask/json-rpc-engine": "^10.2.0", - "@metamask/network-controller": "^26.0.0", + "@metamask/network-controller": "^27.0.0", "@metamask/permission-controller": "^12.1.1", "@metamask/rpc-errors": "^7.0.2", "@metamask/utils": "^11.8.1", diff --git a/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts b/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts index fa1c705843a..c187fe96681 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts @@ -1,8 +1,10 @@ +import type { + Caip25Authorization, + NormalizedScopesObject, +} from '@metamask/chain-agnostic-permission'; import { Caip25CaveatType, Caip25EndowmentPermissionName, - type Caip25Authorization, - type NormalizedScopesObject, KnownSessionProperties, } from '@metamask/chain-agnostic-permission'; import * as ChainAgnosticPermission from '@metamask/chain-agnostic-permission'; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts index bad56056336..cf71fd81390 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts @@ -3,40 +3,42 @@ import { Caip25EndowmentPermissionName, bucketScopes, validateAndNormalizeScopes, - type Caip25Authorization, getInternalScopesObject, getSessionScopes, - type NormalizedScopesObject, getSupportedScopeObjects, - type Caip25CaveatValue, isKnownSessionPropertyValue, getCaipAccountIdsFromScopesObjects, getAllScopesFromScopesObjects, setNonSCACaipAccountIdsInCaip25CaveatValue, isNamespaceInScopesObject, } from '@metamask/chain-agnostic-permission'; +import type { + Caip25Authorization, + NormalizedScopesObject, + Caip25CaveatValue, +} from '@metamask/chain-agnostic-permission'; import { isEqualCaseInsensitive } from '@metamask/controller-utils'; import type { JsonRpcEngineEndCallback, JsonRpcEngineNextCallback, } from '@metamask/json-rpc-engine'; import type { NetworkController } from '@metamask/network-controller'; -import { - invalidParams, - type RequestedPermissions, -} from '@metamask/permission-controller'; +import { invalidParams } from '@metamask/permission-controller'; +import type { RequestedPermissions } from '@metamask/permission-controller'; import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors'; import { - type CaipAccountId, - type CaipChainId, - type Hex, isPlainObject, - type Json, - type JsonRpcRequest, - type JsonRpcSuccess, KnownCaipNamespace, parseCaipAccountId, } from '@metamask/utils'; +import type { + CaipAccountId, + CaipChainId, + Hex, + Json, + JsonRpcRequest, + JsonRpcSuccess, +} from '@metamask/utils'; import type { GrantedPermissions } from './types'; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts index 8911a95e3bd..d18cf807919 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts @@ -14,11 +14,8 @@ import { UnrecognizedSubjectError, } from '@metamask/permission-controller'; import { rpcErrors } from '@metamask/rpc-errors'; -import { - type JsonRpcSuccess, - type JsonRpcRequest, - isObject, -} from '@metamask/utils'; +import { isObject } from '@metamask/utils'; +import type { JsonRpcSuccess, JsonRpcRequest } from '@metamask/utils'; import type { WalletRevokeSessionHooks } from './types'; diff --git a/packages/multichain-network-controller/CHANGELOG.md b/packages/multichain-network-controller/CHANGELOG.md index bdf75a247d2..52e4f68ce20 100644 --- a/packages/multichain-network-controller/CHANGELOG.md +++ b/packages/multichain-network-controller/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7258](https://github.com/MetaMask/core/pull/7258)) + - The dependencies moved are: + - `@metamask/accounts-controller` (^35.0.0) + - `@metamask/network-controller` (^27.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [3.0.0] ### Changed diff --git a/packages/multichain-network-controller/package.json b/packages/multichain-network-controller/package.json index b1525f87a4e..775595ab189 100644 --- a/packages/multichain-network-controller/package.json +++ b/packages/multichain-network-controller/package.json @@ -48,21 +48,21 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/accounts-controller": "^35.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.16.0", "@metamask/keyring-api": "^21.0.0", "@metamask/keyring-internal-api": "^9.0.0", "@metamask/messenger": "^0.3.0", + "@metamask/network-controller": "^27.0.0", "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^11.8.1", "@solana/addresses": "^2.0.0", "lodash": "^4.17.21" }, "devDependencies": { - "@metamask/accounts-controller": "^35.0.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/keyring-controller": "^25.0.0", - "@metamask/network-controller": "^26.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.191", @@ -76,10 +76,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/accounts-controller": "^35.0.0", - "@metamask/network-controller": "^26.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.test.ts b/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.test.ts index eb96dcd6f66..73a0dc72c25 100644 --- a/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.test.ts +++ b/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.test.ts @@ -1,23 +1,24 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import { InfuraNetworkType } from '@metamask/controller-utils'; -import type { AnyAccountType } from '@metamask/keyring-api'; import { BtcScope, SolScope, EthAccountType, BtcAccountType, SolAccountType, - type KeyringAccountType, - type CaipChainId, EthScope, TrxAccountType, } from '@metamask/keyring-api'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import type { + AnyAccountType, + KeyringAccountType, + CaipChainId, +} from '@metamask/keyring-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { NetworkControllerGetStateAction, @@ -26,11 +27,12 @@ import type { NetworkControllerRemoveNetworkAction, NetworkControllerFindNetworkClientIdByChainIdAction, } from '@metamask/network-controller'; -import { KnownCaipNamespace, type CaipAccountId } from '@metamask/utils'; +import { KnownCaipNamespace } from '@metamask/utils'; +import type { CaipAccountId } from '@metamask/utils'; import { MultichainNetworkController } from './MultichainNetworkController'; import { createMockInternalAccount } from '../../tests/utils'; -import { type ActiveNetworksResponse } from '../api/accounts-api'; +import type { ActiveNetworksResponse } from '../api/accounts-api'; import { getDefaultMultichainNetworkControllerState } from '../constants'; import type { AbstractMultichainNetworkService } from '../MultichainNetworkService/AbstractMultichainNetworkService'; import type { MultichainNetworkControllerMessenger } from '../types'; diff --git a/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.ts b/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.ts index e3c065ecda2..a9429ae3a81 100644 --- a/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.ts +++ b/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.ts @@ -2,24 +2,25 @@ import { BaseController } from '@metamask/base-controller'; import { isEvmAccountType } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { NetworkClientId } from '@metamask/network-controller'; -import { type CaipChainId, isCaipChainId } from '@metamask/utils'; +import { isCaipChainId } from '@metamask/utils'; +import type { CaipChainId } from '@metamask/utils'; import { - type ActiveNetworksByAddress, toAllowedCaipAccountIds, toActiveNetworksByAddress, } from '../api/accounts-api'; +import type { ActiveNetworksByAddress } from '../api/accounts-api'; import { AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS, MULTICHAIN_NETWORK_CONTROLLER_METADATA, getDefaultMultichainNetworkControllerState, } from '../constants'; import type { AbstractMultichainNetworkService } from '../MultichainNetworkService/AbstractMultichainNetworkService'; -import { - MULTICHAIN_NETWORK_CONTROLLER_NAME, - type MultichainNetworkControllerState, - type MultichainNetworkControllerMessenger, - type SupportedCaipChainId, +import { MULTICHAIN_NETWORK_CONTROLLER_NAME } from '../types'; +import type { + MultichainNetworkControllerState, + MultichainNetworkControllerMessenger, + SupportedCaipChainId, } from '../types'; import { checkIfSupportedCaipChainId, diff --git a/packages/multichain-network-controller/src/MultichainNetworkService/MultichainNetworkService.test.ts b/packages/multichain-network-controller/src/MultichainNetworkService/MultichainNetworkService.test.ts index 9167a58768f..54d70a3cf02 100644 --- a/packages/multichain-network-controller/src/MultichainNetworkService/MultichainNetworkService.test.ts +++ b/packages/multichain-network-controller/src/MultichainNetworkService/MultichainNetworkService.test.ts @@ -1,13 +1,14 @@ -import { KnownCaipNamespace, type CaipAccountId } from '@metamask/utils'; +import { KnownCaipNamespace } from '@metamask/utils'; +import type { CaipAccountId } from '@metamask/utils'; import { chunk } from 'lodash'; import { MultichainNetworkService } from './MultichainNetworkService'; import { - type ActiveNetworksResponse, MULTICHAIN_ACCOUNTS_CLIENT_HEADER, MULTICHAIN_ACCOUNTS_CLIENT_ID, MULTICHAIN_ACCOUNTS_BASE_URL, } from '../api/accounts-api'; +import type { ActiveNetworksResponse } from '../api/accounts-api'; describe('MultichainNetworkService', () => { beforeEach(() => { diff --git a/packages/multichain-network-controller/src/MultichainNetworkService/MultichainNetworkService.ts b/packages/multichain-network-controller/src/MultichainNetworkService/MultichainNetworkService.ts index 806a583187c..0feccc9c404 100644 --- a/packages/multichain-network-controller/src/MultichainNetworkService/MultichainNetworkService.ts +++ b/packages/multichain-network-controller/src/MultichainNetworkService/MultichainNetworkService.ts @@ -3,12 +3,12 @@ import type { CaipAccountId } from '@metamask/utils'; import { chunk } from 'lodash'; import { - type ActiveNetworksResponse, ActiveNetworksResponseStruct, buildActiveNetworksUrl, MULTICHAIN_ACCOUNTS_CLIENT_HEADER, MULTICHAIN_ACCOUNTS_CLIENT_ID, } from '../api/accounts-api'; +import type { ActiveNetworksResponse } from '../api/accounts-api'; /** * Service responsible for fetching network activity data from the API. diff --git a/packages/multichain-network-controller/src/api/accounts-api.test.ts b/packages/multichain-network-controller/src/api/accounts-api.test.ts index a14111b3d10..7dd66745a40 100644 --- a/packages/multichain-network-controller/src/api/accounts-api.test.ts +++ b/packages/multichain-network-controller/src/api/accounts-api.test.ts @@ -9,20 +9,20 @@ import { TrxAccountType, } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { - type CaipAccountId, - type CaipChainId, - type CaipReference, - KnownCaipNamespace, +import { KnownCaipNamespace } from '@metamask/utils'; +import type { + CaipAccountId, + CaipChainId, + CaipReference, } from '@metamask/utils'; import { - type ActiveNetworksResponse, toAllowedCaipAccountIds, toActiveNetworksByAddress, buildActiveNetworksUrl, MULTICHAIN_ACCOUNTS_BASE_URL, } from './accounts-api'; +import type { ActiveNetworksResponse } from './accounts-api'; const MOCK_ADDRESSES = { evm: '0x1234567890123456789012345678901234567890', diff --git a/packages/multichain-network-controller/src/api/accounts-api.ts b/packages/multichain-network-controller/src/api/accounts-api.ts index cb3c0b41ca0..c5f869ef999 100644 --- a/packages/multichain-network-controller/src/api/accounts-api.ts +++ b/packages/multichain-network-controller/src/api/accounts-api.ts @@ -1,6 +1,7 @@ import { BtcScope, SolScope, EthScope, TrxScope } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { type Infer, array, object } from '@metamask/superstruct'; +import { array, object } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; import { CaipAccountIdStruct, parseCaipAccountId } from '@metamask/utils'; import type { CaipAccountAddress, diff --git a/packages/multichain-network-controller/src/constants.ts b/packages/multichain-network-controller/src/constants.ts index a2790ce1c98..262e79aa6d9 100644 --- a/packages/multichain-network-controller/src/constants.ts +++ b/packages/multichain-network-controller/src/constants.ts @@ -1,10 +1,6 @@ -import { type StateMetadata } from '@metamask/base-controller'; -import { - type CaipChainId, - BtcScope, - SolScope, - TrxScope, -} from '@metamask/keyring-api'; +import type { StateMetadata } from '@metamask/base-controller'; +import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api'; +import type { CaipChainId } from '@metamask/keyring-api'; import { NetworkStatus } from '@metamask/network-controller'; import type { diff --git a/packages/multichain-network-controller/src/types.ts b/packages/multichain-network-controller/src/types.ts index 75f600b4058..11759f883d1 100644 --- a/packages/multichain-network-controller/src/types.ts +++ b/packages/multichain-network-controller/src/types.ts @@ -1,7 +1,7 @@ import type { AccountsControllerListMultichainAccountsAction } from '@metamask/accounts-controller'; -import { - type ControllerGetStateAction, - type ControllerStateChangeEvent, +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; import type { BtcScope, diff --git a/packages/multichain-network-controller/src/utils.test.ts b/packages/multichain-network-controller/src/utils.test.ts index 67330747fc9..c865fcf6105 100644 --- a/packages/multichain-network-controller/src/utils.test.ts +++ b/packages/multichain-network-controller/src/utils.test.ts @@ -1,10 +1,6 @@ -import { - type CaipChainId, - BtcScope, - SolScope, - EthScope, -} from '@metamask/keyring-api'; -import { type NetworkConfiguration } from '@metamask/network-controller'; +import { BtcScope, SolScope, EthScope } from '@metamask/keyring-api'; +import type { CaipChainId } from '@metamask/keyring-api'; +import type { NetworkConfiguration } from '@metamask/network-controller'; import { KnownCaipNamespace } from '@metamask/utils'; import { diff --git a/packages/multichain-network-controller/src/utils.ts b/packages/multichain-network-controller/src/utils.ts index 5364fe9b8f2..5b75506ffbd 100644 --- a/packages/multichain-network-controller/src/utils.ts +++ b/packages/multichain-network-controller/src/utils.ts @@ -1,13 +1,12 @@ import type { NetworkConfiguration } from '@metamask/network-controller'; import { - type Hex, - type CaipChainId, KnownCaipNamespace, toCaipChainId, parseCaipChainId, hexToNumber, add0x, } from '@metamask/utils'; +import type { Hex, CaipChainId } from '@metamask/utils'; import { AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS } from './constants'; import type { diff --git a/packages/multichain-network-controller/tests/utils.ts b/packages/multichain-network-controller/tests/utils.ts index aa0ec3adccd..d87ed46f1e9 100644 --- a/packages/multichain-network-controller/tests/utils.ts +++ b/packages/multichain-network-controller/tests/utils.ts @@ -8,8 +8,8 @@ import { BtcMethod, EthMethod, SolMethod, - type KeyringAccountType, } from '@metamask/keyring-api'; +import type { KeyringAccountType } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; diff --git a/packages/multichain-transactions-controller/CHANGELOG.md b/packages/multichain-transactions-controller/CHANGELOG.md index bab6a40b70b..0b1531f4693 100644 --- a/packages/multichain-transactions-controller/CHANGELOG.md +++ b/packages/multichain-transactions-controller/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/accounts-controller` (^35.0.0) + - `@metamask/snaps-controllers` (^14.0.1) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [7.0.0] ### Changed diff --git a/packages/multichain-transactions-controller/package.json b/packages/multichain-transactions-controller/package.json index 27ee50fe9fb..b3410286456 100644 --- a/packages/multichain-transactions-controller/package.json +++ b/packages/multichain-transactions-controller/package.json @@ -48,12 +48,14 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/accounts-controller": "^35.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/keyring-api": "^21.0.0", "@metamask/keyring-internal-api": "^9.0.0", "@metamask/keyring-snap-client": "^8.0.0", "@metamask/messenger": "^0.3.0", "@metamask/polling-controller": "^16.0.0", + "@metamask/snaps-controllers": "^14.0.1", "@metamask/snaps-sdk": "^9.0.0", "@metamask/snaps-utils": "^11.0.0", "@metamask/utils": "^11.8.1", @@ -62,10 +64,8 @@ "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/accounts-controller": "^35.0.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/keyring-controller": "^25.0.0", - "@metamask/snaps-controllers": "^14.0.1", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -75,10 +75,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/accounts-controller": "^35.0.0", - "@metamask/snaps-controllers": "^14.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts index d2dbbb470e4..2fc19e46a87 100644 --- a/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts @@ -15,12 +15,11 @@ import { } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { CaipChainId } from '@metamask/utils'; import { v4 as uuidv4 } from 'uuid'; @@ -29,8 +28,10 @@ import { MultichainNetwork } from './constants'; import { MultichainTransactionsController, getDefaultMultichainTransactionsControllerState, - type MultichainTransactionsControllerState, - type MultichainTransactionsControllerMessenger, +} from './MultichainTransactionsController'; +import type { + MultichainTransactionsControllerState, + MultichainTransactionsControllerMessenger, } from './MultichainTransactionsController'; const mockBtcAccount = { diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts index cc6834063ae..7846d5c52a8 100644 --- a/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts @@ -4,16 +4,15 @@ import type { AccountsControllerListMultichainAccountsAction, AccountsControllerAccountTransactionsUpdatedEvent, } from '@metamask/accounts-controller'; -import { - BaseController, - type ControllerGetStateAction, - type ControllerStateChangeEvent, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; -import { - isEvmAccountType, - type Transaction, - type AccountTransactionsUpdatedEventPayload, - TransactionStatus, +import { isEvmAccountType, TransactionStatus } from '@metamask/keyring-api'; +import type { + Transaction, + AccountTransactionsUpdatedEventPayload, } from '@metamask/keyring-api'; import type { KeyringControllerGetStateAction } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; @@ -22,11 +21,7 @@ import type { Messenger } from '@metamask/messenger'; import type { HandleSnapRequest } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; import { HandlerType } from '@metamask/snaps-utils'; -import { - type CaipChainId, - type Json, - type JsonRpcRequest, -} from '@metamask/utils'; +import type { CaipChainId, Json, JsonRpcRequest } from '@metamask/utils'; import type { Draft } from 'immer'; const controllerName = 'MultichainTransactionsController'; diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index dd35d64d295..058f37ef027 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Update the default set of Infura networks to include Monad Testnet ([#7067](https://github.com/MetaMask/core/pull/7067)) +- Bump `@metamask/eth-json-rpc-middleware` from `^22.0.0` to `^22.0.1` ([#7330](https://github.com/MetaMask/core/pull/7330)) + +## [27.0.0] + +### Added + +- Add `NetworkController:rpcEndpointChainAvailable` messenger event ([#7166](https://github.com/MetaMask/core/pull/7166)) + - This is a counterpart to the (new) `NetworkController:rpcEndpointChainUnavailable` and `NetworkController:rpcEndpointChainDegraded` events, but is published when a successful request to an endpoint within a chain of endpoints is made either initially or following a previously established degraded or unavailable status. +- Update `networksMetadata` state property so that networks can now have a possible status of `degraded` ([#7186](https://github.com/MetaMask/core/pull/7186)) + +### Changed + +- **BREAKING:** Split up and update payload data for `NetworkController:rpcEndpointDegraded` and `NetworkController:rpcEndpointUnavailable` ([#7166](https://github.com/MetaMask/core/pull/7166)) + - `NetworkController:rpcEndpointDegraded` and `NetworkController:rpcEndpointUnavailable` still exist and retain the same behavior as before. + - New events are `NetworkController:rpcEndpointChainDegraded` and `NetworkController:rpcEndpointChainUnavailable`, and are designed to represent an entire chain of endpoints. They are also guaranteed to not be published multiple times in a row. In particular, `NetworkController:rpcEndpointChainUnavailable` is published only after trying all of the endpoints for a chain and when the underlying circuit for the last endpoint breaks, not as each primary's or failover's circuit breaks. + - The event payloads have been changed: + - For individual endpoint events (`NetworkController:rpcEndpointUnavailable`, `NetworkController:rpcEndpointDegraded`): `failoverEndpointUrl` has been removed, and `primaryEndpointUrl` has been added. In addition, `networkClientId` has been added to the payload. + - For chain-level events (`NetworkController:rpcEndpointChainUnavailable`, `NetworkController:rpcEndpointChainDegraded`, `NetworkController:rpcEndpointChainAvailable`): These include `chainId`, `networkClientId`, and event-specific fields (e.g., `error`, `endpointUrl`) but do not include `primaryEndpointUrl`. Consumers can derive endpoint information from the `networkClientId` using `NetworkController:getNetworkClientById` or `NetworkController:getNetworkConfigurationByNetworkClientId`. +- **BREAKING:** Rename and update payload data for `NetworkController:rpcEndpointRequestRetried` ([#7166](https://github.com/MetaMask/core/pull/7166)) + - This event is now called `NetworkController:rpcEndpointRetried`. + - The event payload has been changed as well: `failoverEndpointUrl` has been removed, and `primaryEndpointUrl` has been added. In addition, `networkClientId` and `attempt` have been added to the payload. +- **BREAKING:** Update `AbstractRpcService`/`RpcServiceRequestable` to remove `{ isolated: true }` from the `onBreak` event data type ([#7166](https://github.com/MetaMask/core/pull/7166)) + - This represented the error produced when `isolate` is called on a Cockatiel circuit breaker policy. This never happens for our service (we use `isolate` internally, but this error is suppressed and cannot trigger `onBreak`) +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/error-reporting-service` (^3.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. +- Automatically update network status metadata when chain-level RPC events are published ([#7186](https://github.com/MetaMask/core/pull/7186)) + - `NetworkController` now automatically subscribes to `NetworkController:rpcEndpointChainUnavailable`, `NetworkController:rpcEndpointChainDegraded`, and `NetworkController:rpcEndpointChainAvailable` events and updates the corresponding network's status metadata in state when these events are published. + - This enables real-time network status updates without requiring explicit `lookupNetwork` calls, providing more accurate and timely network availability information. + ## [26.0.0] ### Added @@ -1015,7 +1051,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/network-controller@26.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/network-controller@27.0.0...HEAD +[27.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@26.0.0...@metamask/network-controller@27.0.0 [26.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@25.0.0...@metamask/network-controller@26.0.0 [25.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@24.3.1...@metamask/network-controller@25.0.0 [24.3.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@24.3.0...@metamask/network-controller@24.3.1 diff --git a/packages/network-controller/package.json b/packages/network-controller/package.json index 89ecd3be9a6..844502bcd00 100644 --- a/packages/network-controller/package.json +++ b/packages/network-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/network-controller", - "version": "26.0.0", + "version": "27.0.0", "description": "Provides an interface to the currently selected network via a MetaMask-compatible provider object", "keywords": [ "MetaMask", @@ -50,9 +50,10 @@ "dependencies": { "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.16.0", + "@metamask/error-reporting-service": "^3.0.0", "@metamask/eth-block-tracker": "^15.0.0", "@metamask/eth-json-rpc-infura": "^10.3.0", - "@metamask/eth-json-rpc-middleware": "^22.0.0", + "@metamask/eth-json-rpc-middleware": "^22.0.1", "@metamask/eth-json-rpc-provider": "^6.0.0", "@metamask/eth-query": "^4.0.0", "@metamask/json-rpc-engine": "^10.2.0", @@ -71,13 +72,13 @@ "devDependencies": { "@json-rpc-specification/meta-schema": "^1.0.6", "@metamask/auto-changelog": "^3.4.4", - "@metamask/error-reporting-service": "^3.0.0", "@ts-bridge/cli": "^0.6.4", "@types/deep-freeze-strict": "^1.1.0", "@types/jest": "^27.4.1", "@types/jest-when": "^2.7.3", "@types/lodash": "^4.14.191", "@types/node-fetch": "^2.6.12", + "cockatiel": "^3.1.2", "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", @@ -91,9 +92,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/error-reporting-service": "^3.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index 2bb04f387ad..13acb49a41d 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -443,9 +443,46 @@ export type NetworkControllerNetworkRemovedEvent = { }; /** - * `rpcEndpointUnavailable` is published after an attempt to make a request to - * an RPC endpoint fails too many times in a row (because of a connection error - * or an unusable response). + * `NetworkController:rpcEndpointChainUnavailable` is published when, after + * trying all endpoints in an endpoint chain, the last failover reaches a + * maximum number of consecutive 5xx responses, breaking the underlying circuit. + * + * In other words, this event will not be published if a failover is available, + * even if the primary is not. + * + * @param payload - The event payload. + * @param payload.chainId - The target network's chain ID. + * @param payload.error - The last error produced by the last failover in the + * endpoint chain. + * @param payload.networkClientId - The target network's client ID. + */ +export type NetworkControllerRpcEndpointChainUnavailableEvent = { + type: 'NetworkController:rpcEndpointChainUnavailable'; + payload: [ + { + chainId: Hex; + error: unknown; + networkClientId: NetworkClientId; + }, + ]; +}; + +/** + * `NetworkController:rpcEndpointUnavailable` is published when any + * endpoint in an endpoint chain reaches a maximum number of consecutive 5xx + * responses, breaking the underlying circuit. + * + * In other words, this event will be published if a primary is not available, + * even if a failover is. + * + * @param payload - The event payload. + * @param payload.chainId - The target network's chain ID. + * @param payload.endpointUrl - The URL of the endpoint which reached the + * maximum number of consecutive 5xx responses. You can compare this to + * `primaryEndpointUrl` to know whether it was a failover or a primary. + * @param payload.error - The last error produced by the endpoint. + * @param payload.networkClientId - The target network's client ID. + * @param payload.primaryEndpointUrl - The endpoint chain's primary URL. */ export type NetworkControllerRpcEndpointUnavailableEvent = { type: 'NetworkController:rpcEndpointUnavailable'; @@ -453,15 +490,65 @@ export type NetworkControllerRpcEndpointUnavailableEvent = { { chainId: Hex; endpointUrl: string; - failoverEndpointUrl?: string; error: unknown; + networkClientId: NetworkClientId; + primaryEndpointUrl: string; + }, + ]; +}; + +/** + * `NetworkController:rpcEndpointChainDegraded` is published for any of the + * endpoints in an endpoint chain when one of the following two conditions hold + * (and the chain is not already in a degraded state): + * + * 1. A successful (2xx) request, even after being retried, cannot be made to + * the endpoint. + * 2. A successful (2xx) request can be made to the endpoint, but it takes + * longer than expected to complete. + * + * Note that this event will be published even if there are local connectivity + * issues which prevent requests from being initiated. This is intentional. + * + * @param payload - The event payload. + * @param payload.chainId - The target network's chain ID. + * @param payload.error - The last error produced by the endpoint (or + * `undefined` if the request was slow). + * @param payload.networkClientId - The target network's client ID. + */ +export type NetworkControllerRpcEndpointChainDegradedEvent = { + type: 'NetworkController:rpcEndpointChainDegraded'; + payload: [ + { + chainId: Hex; + error: unknown; + networkClientId: NetworkClientId; }, ]; }; /** - * `rpcEndpointDegraded` is published after a request to an RPC endpoint - * responds successfully but takes too long. + * + * `NetworkController:rpcEndpointDegraded` is published for any of the endpoints + * in an endpoint chain when: + * + * 1. A successful (2xx) request, even after being retried, cannot be made to + * the endpoint. + * 2. A successful (2xx) request can be made to the endpoint, but it takes + * longer than expected to complete. + * + * Note that this event will be published even if there are local connectivity + * issues which prevent requests from being initiated. This is intentional. + * + * @param payload - The event payload. + * @param payload.chainId - The target network's chain ID. + * @param payload.endpointUrl - The URL of the endpoint for which requests + * failed or were slow to complete. You can compare this to `primaryEndpointUrl` + * to know whether it was a failover or a primary. + * @param payload.error - The last error produced by the endpoint (or + * `undefined` if the request was slow). + * @param payload.networkClientId - The target network's client ID. + * @param payload.primaryEndpointUrl - The endpoint chain's primary URL. */ export type NetworkControllerRpcEndpointDegradedEvent = { type: 'NetworkController:rpcEndpointDegraded'; @@ -470,20 +557,59 @@ export type NetworkControllerRpcEndpointDegradedEvent = { chainId: Hex; endpointUrl: string; error: unknown; + networkClientId: NetworkClientId; + primaryEndpointUrl: string; }, ]; }; /** - * `rpcEndpointRequestRetried` is published after a request to an RPC endpoint - * is retried following a connection error or an unusable response. + * `NetworkController:rpcEndpointChainAvailable` is published in one of two + * cases: + * + * 1. The first time that a 2xx request is made to any of the endpoints in an + * endpoint chain. + * 2. When requests to any of the endpoints previously failed (placing the + * endpoint in a degraded or unavailable status), but are now succeeding again. + * + * @param payload - The event payload. + * @param payload.chainId - The target network's chain ID. + * @param payload.networkClientId - The target network's client ID. */ -export type NetworkControllerRpcEndpointRequestRetriedEvent = { - type: 'NetworkController:rpcEndpointRequestRetried'; +export type NetworkControllerRpcEndpointChainAvailableEvent = { + type: 'NetworkController:rpcEndpointChainAvailable'; + payload: [ + { + chainId: Hex; + networkClientId: NetworkClientId; + }, + ]; +}; + +/** + * `NetworkController:rpcEndpointRetried` is published before a request to any + * endpoint in an endpoint chain is retried. + * + * This is mainly useful for tests. + * + * @param payload - The event payload. + * @param payload.attempt - The current attempt counter for the endpoint + * (starting from 0). + * @param payload.chainId - The target network's chain ID. + * @param payload.endpointUrl - The URL of the endpoint being retried. + * @param payload.networkClientId - The target network's client ID. + * @param payload.primaryEndpointUrl - The endpoint chain's primary URL. + * @see {@link RpcService} for the list of retriable errors. + */ +export type NetworkControllerRpcEndpointRetriedEvent = { + type: 'NetworkController:rpcEndpointRetried'; payload: [ { - endpointUrl: string; attempt: number; + chainId: Hex; + endpointUrl: string; + networkClientId: NetworkClientId; + primaryEndpointUrl: string; }, ]; }; @@ -496,9 +622,12 @@ export type NetworkControllerEvents = | NetworkControllerInfuraIsUnblockedEvent | NetworkControllerNetworkAddedEvent | NetworkControllerNetworkRemovedEvent + | NetworkControllerRpcEndpointChainUnavailableEvent | NetworkControllerRpcEndpointUnavailableEvent + | NetworkControllerRpcEndpointChainDegradedEvent | NetworkControllerRpcEndpointDegradedEvent - | NetworkControllerRpcEndpointRequestRetriedEvent; + | NetworkControllerRpcEndpointChainAvailableEvent + | NetworkControllerRpcEndpointRetriedEvent; /** * All events that {@link NetworkController} calls internally. @@ -934,7 +1063,6 @@ type NoopNetworkClientOperation = { rpcEndpoint: RpcEndpoint; }; -/* eslint-disable jsdoc/check-indentation */ /** * Instructs `addNetwork`, `updateNetwork`, and `removeNetwork` how to * update the network client registry. @@ -952,7 +1080,6 @@ type NoopNetworkClientOperation = { * - a network client that should be unchanged for an RPC endpoint that was * also unchanged. */ -/* eslint-enable jsdoc/check-indentation */ type NetworkClientOperation = | AddNetworkClientOperation | RemoveNetworkClientOperation @@ -1310,6 +1437,31 @@ export class NetworkController extends BaseController< `${this.name}:updateNetwork`, this.updateNetwork.bind(this), ); + + this.messenger.subscribe( + `${this.name}:rpcEndpointChainUnavailable`, + ({ networkClientId }) => { + this.#updateMetadataForNetwork(networkClientId, { + networkStatus: NetworkStatus.Unavailable, + }); + }, + ); + this.messenger.subscribe( + `${this.name}:rpcEndpointChainDegraded`, + ({ networkClientId }) => { + this.#updateMetadataForNetwork(networkClientId, { + networkStatus: NetworkStatus.Degraded, + }); + }, + ); + this.messenger.subscribe( + `${this.name}:rpcEndpointChainAvailable`, + ({ networkClientId }) => { + this.#updateMetadataForNetwork(networkClientId, { + networkStatus: NetworkStatus.Available, + }); + }, + ); } /** @@ -1717,11 +1869,11 @@ export class NetworkController extends BaseController< async #lookupGivenNetwork(networkClientId: NetworkClientId) { const { networkStatus, isEIP1559Compatible } = await this.#determineNetworkMetadata(networkClientId); - this.#updateMetadataForNetwork( - networkClientId, + + this.#updateMetadataForNetwork(networkClientId, { networkStatus, isEIP1559Compatible, - ); + }); } /** @@ -1796,11 +1948,10 @@ export class NetworkController extends BaseController< } } - this.#updateMetadataForNetwork( - this.state.selectedNetworkClientId, + this.#updateMetadataForNetwork(this.state.selectedNetworkClientId, { networkStatus, isEIP1559Compatible, - ); + }); if (isInfura) { if (networkStatus === NetworkStatus.Available) { @@ -1820,14 +1971,17 @@ export class NetworkController extends BaseController< * Updates the metadata for the given network in state. * * @param networkClientId - The associated network client ID. - * @param networkStatus - The network status to store in state. - * @param isEIP1559Compatible - The EIP-1559 compatibility status to + * @param metadata - The metadata to store in state. + * @param metadata.networkStatus - The network status to store in state. + * @param metadata.isEIP1559Compatible - The EIP-1559 compatibility status to * store in state. */ #updateMetadataForNetwork( networkClientId: NetworkClientId, - networkStatus: NetworkStatus, - isEIP1559Compatible: boolean | undefined, + metadata: { + networkStatus: NetworkStatus; + isEIP1559Compatible?: boolean | undefined; + }, ) { this.update((state) => { if (state.networksMetadata[networkClientId] === undefined) { @@ -1836,12 +1990,15 @@ export class NetworkController extends BaseController< EIPS: {}, }; } - const meta = state.networksMetadata[networkClientId]; - meta.status = networkStatus; - if (isEIP1559Compatible === undefined) { - delete meta.EIPS[1559]; - } else { - meta.EIPS[1559] = isEIP1559Compatible; + const newMetadata = state.networksMetadata[networkClientId]; + newMetadata.status = metadata.networkStatus; + + if ('isEIP1559Compatible' in metadata) { + if (metadata.isEIP1559Compatible === undefined) { + delete newMetadata.EIPS[1559]; + } else { + newMetadata.EIPS[1559] = metadata.isEIP1559Compatible; + } } }); } @@ -2800,6 +2957,7 @@ export class NetworkController extends BaseController< autoManagedNetworkClientRegistry[NetworkClientType.Infura][ addedRpcEndpoint.networkClientId ] = createAutoManagedNetworkClient({ + networkClientId: addedRpcEndpoint.networkClientId, networkClientConfiguration: { type: NetworkClientType.Infura, chainId: networkFields.chainId, @@ -2818,6 +2976,7 @@ export class NetworkController extends BaseController< autoManagedNetworkClientRegistry[NetworkClientType.Custom][ addedRpcEndpoint.networkClientId ] = createAutoManagedNetworkClient({ + networkClientId: addedRpcEndpoint.networkClientId, networkClientConfiguration: { type: NetworkClientType.Custom, chainId: networkFields.chainId, @@ -2980,6 +3139,7 @@ export class NetworkController extends BaseController< return [ rpcEndpoint.networkClientId, createAutoManagedNetworkClient({ + networkClientId: rpcEndpoint.networkClientId, networkClientConfiguration: { type: NetworkClientType.Infura, network: infuraNetworkName, @@ -2999,6 +3159,7 @@ export class NetworkController extends BaseController< return [ rpcEndpoint.networkClientId, createAutoManagedNetworkClient({ + networkClientId: rpcEndpoint.networkClientId, networkClientConfiguration: { type: NetworkClientType.Custom, chainId: networkConfiguration.chainId, diff --git a/packages/network-controller/src/constants.ts b/packages/network-controller/src/constants.ts index bdccc1f57aa..1708cc2ebf6 100644 --- a/packages/network-controller/src/constants.ts +++ b/packages/network-controller/src/constants.ts @@ -1,26 +1,34 @@ /** - * Represents the availability state of the currently selected network. + * Represents the availability status of an RPC endpoint. (Regrettably, the + * name of this type is a misnomer.) + * + * The availability status is set both automatically (as requests are made) and + * manually (when `lookupNetwork` is called). */ export enum NetworkStatus { /** - * The network may or may not be able to receive requests, but either no - * attempt has been made to determine this, or an attempt was made but was - * unsuccessful. + * Either the availability status of the RPC endpoint has not been determined, + * or request that `lookupNetwork` performed returned an unknown error. */ Unknown = 'unknown', /** - * The network is able to receive and respond to requests. + * The RPC endpoint is consistently returning successful (2xx) responses. */ Available = 'available', /** - * The network was unable to receive and respond to requests for unknown - * reasons. + * Either the last request to the RPC endpoint was either too slow, or the + * endpoint is consistently returning errors and the number of retries has + * been reached. + */ + Degraded = 'degraded', + /** + * The RPC endpoint is consistently returning enough 5xx errors that requests + * have been paused. */ Unavailable = 'unavailable', /** - * The network is not only unavailable, but is also inaccessible for the user - * specifically based on their location. This state only applies to Infura - * networks. + * The RPC endpoint is inaccessible for the user based on their location. This + * status only applies to Infura networks. */ Blocked = 'blocked', } diff --git a/packages/network-controller/src/create-auto-managed-network-client.test.ts b/packages/network-controller/src/create-auto-managed-network-client.test.ts index 3b49ccda1f5..c30208ce167 100644 --- a/packages/network-controller/src/create-auto-managed-network-client.test.ts +++ b/packages/network-controller/src/create-auto-managed-network-client.test.ts @@ -35,6 +35,7 @@ describe('createAutoManagedNetworkClient', () => { describe(`given configuration for a ${networkClientConfiguration.type} network client`, () => { it('allows the network client configuration to be accessed', () => { const { configuration } = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', networkClientConfiguration, getRpcServiceOptions: () => ({ fetch, @@ -51,6 +52,7 @@ describe('createAutoManagedNetworkClient', () => { // If unexpected requests occurred, then Nock would throw expect(() => { createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', networkClientConfiguration, getRpcServiceOptions: () => ({ fetch, @@ -64,6 +66,7 @@ describe('createAutoManagedNetworkClient', () => { it('returns a provider proxy that has the same interface as a provider', () => { const { provider } = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', networkClientConfiguration, getRpcServiceOptions: () => ({ fetch, @@ -97,6 +100,7 @@ describe('createAutoManagedNetworkClient', () => { }); const { provider } = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', networkClientConfiguration, getRpcServiceOptions: () => ({ fetch, @@ -145,6 +149,7 @@ describe('createAutoManagedNetworkClient', () => { const messenger = buildNetworkControllerMessenger(); const { provider } = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, @@ -166,6 +171,7 @@ describe('createAutoManagedNetworkClient', () => { }); expect(createNetworkClientMock).toHaveBeenCalledTimes(1); expect(createNetworkClientMock).toHaveBeenCalledWith({ + id: 'some-network-client-id', configuration: networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, @@ -204,6 +210,7 @@ describe('createAutoManagedNetworkClient', () => { const messenger = buildNetworkControllerMessenger(); const autoManagedNetworkClient = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, @@ -227,6 +234,7 @@ describe('createAutoManagedNetworkClient', () => { }); expect(createNetworkClientMock).toHaveBeenNthCalledWith(1, { + id: 'some-network-client-id', configuration: networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, @@ -234,6 +242,7 @@ describe('createAutoManagedNetworkClient', () => { isRpcFailoverEnabled: false, }); expect(createNetworkClientMock).toHaveBeenNthCalledWith(2, { + id: 'some-network-client-id', configuration: networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, @@ -272,6 +281,7 @@ describe('createAutoManagedNetworkClient', () => { const messenger = buildNetworkControllerMessenger(); const autoManagedNetworkClient = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, @@ -295,6 +305,7 @@ describe('createAutoManagedNetworkClient', () => { }); expect(createNetworkClientMock).toHaveBeenNthCalledWith(1, { + id: 'some-network-client-id', configuration: networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, @@ -302,6 +313,7 @@ describe('createAutoManagedNetworkClient', () => { isRpcFailoverEnabled: true, }); expect(createNetworkClientMock).toHaveBeenNthCalledWith(2, { + id: 'some-network-client-id', configuration: networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, @@ -313,6 +325,7 @@ describe('createAutoManagedNetworkClient', () => { it('returns a block tracker proxy that has the same interface as a block tracker', () => { const { blockTracker } = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', networkClientConfiguration, getRpcServiceOptions: () => ({ fetch, @@ -372,6 +385,7 @@ describe('createAutoManagedNetworkClient', () => { }); const { blockTracker } = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', networkClientConfiguration, getRpcServiceOptions: () => ({ fetch, @@ -441,6 +455,7 @@ describe('createAutoManagedNetworkClient', () => { const messenger = buildNetworkControllerMessenger(); const { blockTracker } = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, @@ -458,6 +473,7 @@ describe('createAutoManagedNetworkClient', () => { await blockTracker.checkForLatestBlock(); expect(createNetworkClientMock).toHaveBeenCalledTimes(1); expect(createNetworkClientMock).toHaveBeenCalledWith({ + id: 'some-network-client-id', configuration: networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, @@ -496,6 +512,7 @@ describe('createAutoManagedNetworkClient', () => { const messenger = buildNetworkControllerMessenger(); const autoManagedNetworkClient = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, @@ -513,6 +530,7 @@ describe('createAutoManagedNetworkClient', () => { }); expect(createNetworkClientMock).toHaveBeenNthCalledWith(1, { + id: 'some-network-client-id', configuration: networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, @@ -520,6 +538,7 @@ describe('createAutoManagedNetworkClient', () => { isRpcFailoverEnabled: false, }); expect(createNetworkClientMock).toHaveBeenNthCalledWith(2, { + id: 'some-network-client-id', configuration: networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, @@ -558,6 +577,7 @@ describe('createAutoManagedNetworkClient', () => { const messenger = buildNetworkControllerMessenger(); const autoManagedNetworkClient = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, @@ -575,6 +595,7 @@ describe('createAutoManagedNetworkClient', () => { }); expect(createNetworkClientMock).toHaveBeenNthCalledWith(1, { + id: 'some-network-client-id', configuration: networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, @@ -582,6 +603,7 @@ describe('createAutoManagedNetworkClient', () => { isRpcFailoverEnabled: true, }); expect(createNetworkClientMock).toHaveBeenNthCalledWith(2, { + id: 'some-network-client-id', configuration: networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, @@ -608,6 +630,7 @@ describe('createAutoManagedNetworkClient', () => { ], }); const { blockTracker, destroy } = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', networkClientConfiguration, getRpcServiceOptions: () => ({ fetch, diff --git a/packages/network-controller/src/create-auto-managed-network-client.ts b/packages/network-controller/src/create-auto-managed-network-client.ts index 5ab700a1737..3bdded89d83 100644 --- a/packages/network-controller/src/create-auto-managed-network-client.ts +++ b/packages/network-controller/src/create-auto-managed-network-client.ts @@ -3,7 +3,10 @@ import type { Logger } from 'loglevel'; import type { NetworkClient } from './create-network-client'; import { createNetworkClient } from './create-network-client'; -import type { NetworkControllerMessenger } from './NetworkController'; +import type { + NetworkClientId, + NetworkControllerMessenger, +} from './NetworkController'; import type { RpcServiceOptions } from './rpc-service/rpc-service'; import type { BlockTracker, @@ -65,6 +68,8 @@ const UNINITIALIZED_TARGET = { __UNINITIALIZED__: true }; * then cached for subsequent usages. * * @param args - The arguments. + * @param args.networkClientId - The ID that will be assigned to the new network + * client in the registry. * @param args.networkClientConfiguration - The configuration object that will be * used to instantiate the network client when it is needed. * @param args.getRpcServiceOptions - Factory for constructing RPC service @@ -81,6 +86,7 @@ const UNINITIALIZED_TARGET = { __UNINITIALIZED__: true }; export function createAutoManagedNetworkClient< Configuration extends NetworkClientConfiguration, >({ + networkClientId, networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions = () => ({}), @@ -88,6 +94,7 @@ export function createAutoManagedNetworkClient< isRpcFailoverEnabled: givenIsRpcFailoverEnabled, logger, }: { + networkClientId: NetworkClientId; networkClientConfiguration: Configuration; getRpcServiceOptions: ( rpcEndpointUrl: string, @@ -104,6 +111,7 @@ export function createAutoManagedNetworkClient< const ensureNetworkClientCreated = (): NetworkClient => { networkClient ??= createNetworkClient({ + id: networkClientId, configuration: networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, diff --git a/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts b/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts new file mode 100644 index 00000000000..9e892be54d8 --- /dev/null +++ b/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts @@ -0,0 +1,1281 @@ +import { + ConstantBackoff, + DEFAULT_DEGRADED_THRESHOLD, + HttpError, +} from '@metamask/controller-utils'; +import { errorCodes } from '@metamask/rpc-errors'; + +import { buildRootMessenger } from '../../tests/helpers'; +import { + withMockedCommunications, + withNetworkClient, +} from '../../tests/network-client/helpers'; +import { DEFAULT_MAX_CONSECUTIVE_FAILURES } from '../rpc-service/rpc-service'; +import { NetworkClientType } from '../types'; + +describe('createNetworkClient - RPC endpoint events', () => { + for (const networkClientType of Object.values(NetworkClientType)) { + describe(`${networkClientType}`, () => { + const blockNumber = '0x100'; + const backoffDuration = 100; + + describe('with RPC failover', () => { + it('publishes the NetworkController:rpcEndpointChainUnavailable event only when the max number of consecutive request failures is reached for all of the endpoints in a chain of endpoints', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + const expectedUnavailableError = new HttpError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async (failoverComms) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + failoverComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + + const messenger = buildRootMessenger(); + const rpcEndpointChainUnavailableEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointChainUnavailable', + rpcEndpointChainUnavailableEventHandler, + ); + + await withNetworkClient( + { + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + providerType: networkClientType, + isRpcFailoverEnabled: true, + failoverRpcUrls: [failoverEndpointUrl], + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall, clock, chainId }) => { + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + clock.tick(backoffDuration); + }, + ); + + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries, + // breaking the circuit; then hit the failover and exceed + // the max of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the failover and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the failover and exceed the max number of retries, + // breaking the circuit + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + expect( + rpcEndpointChainUnavailableEventHandler, + ).toHaveBeenCalledTimes(1); + expect( + rpcEndpointChainUnavailableEventHandler, + ).toHaveBeenCalledWith({ + chainId, + error: expectedUnavailableError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }); + }, + ); + }, + ); + }, + ); + }); + + it('publishes the NetworkController:rpcEndpointUnavailable event each time the max number of consecutive request failures is reached for any of the endpoints in a chain of endpoints', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + const expectedUnavailableError = new HttpError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async (failoverComms) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + failoverComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + + const messenger = buildRootMessenger(); + const rpcEndpointUnavailableEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointUnavailable', + rpcEndpointUnavailableEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + isRpcFailoverEnabled: true, + failoverRpcUrls: [failoverEndpointUrl], + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall, clock, chainId, rpcUrl }) => { + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + clock.tick(backoffDuration); + }, + ); + + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries, + // breaking the circuit; then hit the failover and exceed + // the max of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the failover and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the failover and exceed the max number of retries, + // breaking the circuit + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + expect( + rpcEndpointUnavailableEventHandler, + ).toHaveBeenCalledTimes(2); + expect( + rpcEndpointUnavailableEventHandler, + ).toHaveBeenCalledWith({ + chainId, + endpointUrl: rpcUrl, + error: expectedUnavailableError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + }); + expect( + rpcEndpointUnavailableEventHandler, + ).toHaveBeenCalledWith({ + chainId, + endpointUrl: failoverEndpointUrl, + error: expectedUnavailableError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + }); + }, + ); + }, + ); + }, + ); + }); + + it('does not publish the NetworkController:rpcEndpointChainDegraded event again if the max number of retries is reached in making requests to a failover endpoint', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async (failoverComms) => { + const messenger = buildRootMessenger(); + const rpcEndpointChainDegradedEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointChainDegraded', + rpcEndpointChainDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + isRpcFailoverEnabled: true, + failoverRpcUrls: [failoverEndpointUrl], + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall, clock, chainId }) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + failoverComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: 5, + response: { + httpStatus: 503, + }, + }); + + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + clock.tick(backoffDuration); + }, + ); + + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries, + // break the circuit; hit the failover and exceed the max + // number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + expect( + rpcEndpointChainDegradedEventHandler, + ).toHaveBeenCalledTimes(1); + expect( + rpcEndpointChainDegradedEventHandler, + ).toHaveBeenCalledWith({ + chainId, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }); + }, + ); + }, + ); + }, + ); + }); + + it('does not publish the NetworkController:rpcEndpointChainDegraded event again when the time to complete a request to a failover endpoint is too long', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async (failoverComms) => { + const messenger = buildRootMessenger(); + const rpcEndpointChainDegradedEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointChainDegraded', + rpcEndpointChainDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + isRpcFailoverEnabled: true, + failoverRpcUrls: [failoverEndpointUrl], + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall, clock, chainId }) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + failoverComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + response: () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + result: '0x1', + }; + }, + }); + failoverComms.mockRpcCall({ + request, + response: () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + result: 'ok', + }; + }, + }); + + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + clock.tick(backoffDuration); + }, + ); + + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries, + // break the circuit; hit the failover + await makeRpcCall(request); + + expect( + rpcEndpointChainDegradedEventHandler, + ).toHaveBeenCalledTimes(1); + expect( + rpcEndpointChainDegradedEventHandler, + ).toHaveBeenCalledWith({ + chainId, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }); + }, + ); + }, + ); + }, + ); + }); + + it('publishes the NetworkController:rpcEndpointDegraded event again if the max number of retries is reached in making requests to a failover endpoint', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async (failoverComms) => { + const messenger = buildRootMessenger(); + const rpcEndpointDegradedEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointDegraded', + rpcEndpointDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + isRpcFailoverEnabled: true, + failoverRpcUrls: [failoverEndpointUrl], + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall, clock, chainId, rpcUrl }) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + failoverComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: 5, + response: { + httpStatus: 503, + }, + }); + + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + clock.tick(backoffDuration); + }, + ); + + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries, + // break the circuit; hit the failover and exceed the max + // number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenCalledTimes(3); + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenNthCalledWith(1, { + chainId, + endpointUrl: rpcUrl, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + }); + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenNthCalledWith(2, { + chainId, + endpointUrl: rpcUrl, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + }); + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenNthCalledWith(3, { + chainId, + endpointUrl: failoverEndpointUrl, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + }); + }, + ); + }, + ); + }, + ); + }); + + it('publishes the NetworkController:rpcEndpointDegraded event again when the time to complete a request to a failover endpoint is too long', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async (failoverComms) => { + const messenger = buildRootMessenger(); + const rpcEndpointDegradedEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointDegraded', + rpcEndpointDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + isRpcFailoverEnabled: true, + failoverRpcUrls: [failoverEndpointUrl], + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall, clock, chainId, rpcUrl }) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + failoverComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + response: () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + result: '0x1', + }; + }, + }); + failoverComms.mockRpcCall({ + request, + response: () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + result: 'ok', + }; + }, + }); + + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + clock.tick(backoffDuration); + }, + ); + + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries, + // break the circuit; hit the failover + await makeRpcCall(request); + + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenCalledTimes(4); + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenNthCalledWith(1, { + chainId, + endpointUrl: rpcUrl, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + }); + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenNthCalledWith(2, { + chainId, + endpointUrl: rpcUrl, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + }); + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenNthCalledWith(3, { + chainId, + endpointUrl: failoverEndpointUrl, + error: undefined, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + }); + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenNthCalledWith(4, { + chainId, + endpointUrl: failoverEndpointUrl, + error: undefined, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + }); + }, + ); + }, + ); + }, + ); + }); + + it('publishes the NetworkController:rpcEndpointChainAvailable event the first time a successful request to a failover endpoint is made', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async (failoverComms) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + failoverComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + response: { + result: '0x1', + }, + }); + failoverComms.mockRpcCall({ + request, + response: { + result: 'ok', + }, + }); + + const messenger = buildRootMessenger(); + const rpcEndpointChainAvailableEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointChainAvailable', + rpcEndpointChainAvailableEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + isRpcFailoverEnabled: true, + failoverRpcUrls: [failoverEndpointUrl], + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall, clock, chainId }) => { + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + clock.tick(backoffDuration); + }, + ); + + // Hit the endpoint and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the endpoint and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the endpoint and exceed the max number of retries, + // breaking the circuit; hit the failover + await makeRpcCall(request); + + expect( + rpcEndpointChainAvailableEventHandler, + ).toHaveBeenCalledTimes(1); + expect( + rpcEndpointChainAvailableEventHandler, + ).toHaveBeenCalledWith({ + chainId, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }); + }, + ); + }, + ); + }, + ); + }); + }); + + describe('without RPC failover', () => { + it('publishes the NetworkController:rpcEndpointChainDegraded event only once, even if the max number of retries is continually reached in making requests to a primary endpoint', async () => { + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (comms) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + comms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + + const messenger = buildRootMessenger(); + const rpcEndpointChainDegradedEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointChainDegraded', + rpcEndpointChainDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall, clock, chainId }) => { + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + clock.tick(backoffDuration); + }, + ); + + // Hit the endpoint and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the endpoint and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the endpoint and exceed the max number of retries, + // breaking the circuit + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + expect( + rpcEndpointChainDegradedEventHandler, + ).toHaveBeenCalledTimes(1); + expect( + rpcEndpointChainDegradedEventHandler, + ).toHaveBeenCalledWith({ + chainId, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }); + }, + ); + }, + ); + }); + + it('publishes the NetworkController:rpcEndpointChainDegraded event only once, even if the time to complete a request to a primary endpoint is continually too long', async () => { + const request = { + method: 'eth_gasPrice', + params: [], + }; + + await withMockedCommunications( + { providerType: networkClientType }, + async (comms) => { + const messenger = buildRootMessenger(); + const rpcEndpointChainDegradedEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointChainDegraded', + rpcEndpointChainDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall, clock, chainId }) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + comms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + response: () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + result: '0x1', + }; + }, + }); + comms.mockRpcCall({ + request, + response: () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + result: 'ok', + }; + }, + times: 2, + }); + + await makeRpcCall(request); + await makeRpcCall(request); + + expect( + rpcEndpointChainDegradedEventHandler, + ).toHaveBeenCalledTimes(1); + expect( + rpcEndpointChainDegradedEventHandler, + ).toHaveBeenCalledWith({ + chainId, + error: undefined, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }); + }, + ); + }, + ); + }); + + it('publishes the NetworkController:rpcEndpointDegraded event each time the max number of retries is reached in making requests to a primary endpoint', async () => { + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (comms) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + comms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + + const messenger = buildRootMessenger(); + const rpcEndpointDegradedEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointDegraded', + rpcEndpointDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall, clock, chainId, rpcUrl }) => { + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + clock.tick(backoffDuration); + }, + ); + + // Hit the endpoint and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the endpoint and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the endpoint and exceed the max number of retries, + // breaking the circuit + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledTimes( + 2, + ); + expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({ + chainId, + endpointUrl: rpcUrl, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + }); + expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({ + chainId, + endpointUrl: rpcUrl, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + }); + }, + ); + }, + ); + }); + + it('publishes the NetworkController:rpcEndpointDegraded event when the time to complete a request to a primary endpoint is continually too long', async () => { + const request = { + method: 'eth_gasPrice', + params: [], + }; + + await withMockedCommunications( + { providerType: networkClientType }, + async (comms) => { + const messenger = buildRootMessenger(); + const rpcEndpointDegradedEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointDegraded', + rpcEndpointDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + messenger, + getBlockTrackerOptions: () => ({ + pollingInterval: 10000, + }), + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall, clock, chainId, rpcUrl }) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + comms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + response: () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + result: '0x1', + }; + }, + }); + comms.mockRpcCall({ + request, + response: () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + result: 'ok', + }; + }, + }); + + await makeRpcCall(request); + + expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledTimes( + 2, + ); + expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({ + chainId, + endpointUrl: rpcUrl, + error: undefined, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + }); + expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({ + chainId, + endpointUrl: rpcUrl, + error: undefined, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + }); + }, + ); + }, + ); + }); + + it('publishes the NetworkController:rpcEndpointChainAvailable event the first time a successful request to a (primary) RPC endpoint is made', async () => { + const request = { + method: 'eth_gasPrice', + params: [], + }; + + await withMockedCommunications( + { providerType: networkClientType }, + async (comms) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + comms.mockNextBlockTrackerRequest({ + blockNumber, + }); + comms.mockRpcCall({ + request, + response: { + result: 'ok', + }, + }); + + const messenger = buildRootMessenger(); + const rpcEndpointChainAvailableEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointChainAvailable', + rpcEndpointChainAvailableEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall, chainId }) => { + await makeRpcCall(request); + + expect( + rpcEndpointChainAvailableEventHandler, + ).toHaveBeenCalledTimes(1); + expect( + rpcEndpointChainAvailableEventHandler, + ).toHaveBeenCalledWith({ + chainId, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }); + }, + ); + }, + ); + }); + }); + }); + } +}); + +/** + * Creates a "resource unavailable" RPC error for testing. + * + * @param httpStatus - The HTTP status that the error represents. + * @returns The RPC error. + */ +function createResourceUnavailableError(httpStatus: number) { + return expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus, + }, + }); +} diff --git a/packages/network-controller/src/create-network-client.ts b/packages/network-controller/src/create-network-client.ts index 8ae4565b767..5fbb9d256ea 100644 --- a/packages/network-controller/src/create-network-client.ts +++ b/packages/network-controller/src/create-network-client.ts @@ -1,4 +1,7 @@ -import type { InfuraNetworkType } from '@metamask/controller-utils'; +import type { + CockatielFailureReason, + InfuraNetworkType, +} from '@metamask/controller-utils'; import { ChainId } from '@metamask/controller-utils'; import type { PollingBlockTrackerOptions } from '@metamask/eth-block-tracker'; import { PollingBlockTracker } from '@metamask/eth-block-tracker'; @@ -26,7 +29,10 @@ import type { import type { Hex, Json, JsonRpcRequest } from '@metamask/utils'; import type { Logger } from 'loglevel'; -import type { NetworkControllerMessenger } from './NetworkController'; +import type { + NetworkClientId, + NetworkControllerMessenger, +} from './NetworkController'; import type { RpcServiceOptions } from './rpc-service/rpc-service'; import { RpcServiceChain } from './rpc-service/rpc-service-chain'; import type { @@ -59,6 +65,8 @@ type RpcApiMiddleware = JsonRpcMiddleware< * Create a JSON RPC network client for a specific network. * * @param args - The arguments. + * @param args.id - The ID that will be assigned to the new network client in + * the registry. * @param args.configuration - The network configuration. * @param args.getRpcServiceOptions - Factory for constructing RPC service * options. See {@link NetworkControllerOptions.getRpcServiceOptions}. @@ -74,6 +82,7 @@ type RpcApiMiddleware = JsonRpcMiddleware< * @returns The network client. */ export function createNetworkClient({ + id, configuration, getRpcServiceOptions, getBlockTrackerOptions, @@ -81,6 +90,7 @@ export function createNetworkClient({ isRpcFailoverEnabled, logger, }: { + id: NetworkClientId; configuration: NetworkClientConfiguration; getRpcServiceOptions: ( rpcEndpointUrl: string, @@ -96,50 +106,14 @@ export function createNetworkClient({ configuration.type === NetworkClientType.Infura ? `https://${configuration.network}.infura.io/v3/${configuration.infuraProjectId}` : configuration.rpcUrl; - const availableEndpointUrls = isRpcFailoverEnabled - ? [primaryEndpointUrl, ...(configuration.failoverRpcUrls ?? [])] - : [primaryEndpointUrl]; - const rpcServiceChain = new RpcServiceChain( - availableEndpointUrls.map((endpointUrl) => ({ - ...getRpcServiceOptions(endpointUrl), - endpointUrl, - logger, - })), - ); - rpcServiceChain.onBreak(({ endpointUrl, failoverEndpointUrl, ...rest }) => { - let error: unknown; - if ('error' in rest) { - error = rest.error; - } else if ('value' in rest) { - error = rest.value; - } - - messenger.publish('NetworkController:rpcEndpointUnavailable', { - chainId: configuration.chainId, - endpointUrl, - failoverEndpointUrl, - error, - }); - }); - rpcServiceChain.onDegraded(({ endpointUrl, ...rest }) => { - let error: unknown; - if ('error' in rest) { - error = rest.error; - } else if ('value' in rest) { - error = rest.value; - } - - messenger.publish('NetworkController:rpcEndpointDegraded', { - chainId: configuration.chainId, - endpointUrl, - error, - }); - }); - rpcServiceChain.onRetry(({ endpointUrl, attempt }) => { - messenger.publish('NetworkController:rpcEndpointRequestRetried', { - endpointUrl, - attempt, - }); + const rpcServiceChain = createRpcServiceChain({ + id, + primaryEndpointUrl, + configuration, + getRpcServiceOptions, + messenger, + isRpcFailoverEnabled, + logger, }); let rpcApiMiddleware: RpcApiMiddleware; @@ -194,6 +168,179 @@ export function createNetworkClient({ return { configuration, provider, blockTracker, destroy }; } +/** + * Creates an RPC service chain, which represents the primary endpoint URL for + * the network as well as its failover URLs. + * + * @param args - The arguments. + * @param args.id - The ID that will be assigned to the new network client in + * the registry. + * @param args.primaryEndpointUrl - The primary endpoint URL. + * @param args.configuration - The network configuration. + * @param args.getRpcServiceOptions - Factory for constructing RPC service + * options. See {@link NetworkControllerOptions.getRpcServiceOptions}. + * @param args.messenger - The network controller messenger. + * @param args.isRpcFailoverEnabled - Whether or not requests sent to the + * primary RPC endpoint for this network should be automatically diverted to + * provided failover endpoints if the primary is unavailable. This effectively + * causes the `failoverRpcUrls` property of the network client configuration + * to be honored or ignored. + * @param args.logger - A `loglevel` logger. + * @returns The RPC service chain. + */ +function createRpcServiceChain({ + id, + primaryEndpointUrl, + configuration, + getRpcServiceOptions, + messenger, + isRpcFailoverEnabled, + logger, +}: { + id: NetworkClientId; + primaryEndpointUrl: string; + configuration: NetworkClientConfiguration; + getRpcServiceOptions: ( + rpcEndpointUrl: string, + ) => Omit; + messenger: NetworkControllerMessenger; + isRpcFailoverEnabled: boolean; + logger?: Logger; +}) { + const availableEndpointUrls: [string, ...string[]] = isRpcFailoverEnabled + ? [primaryEndpointUrl, ...(configuration.failoverRpcUrls ?? [])] + : [primaryEndpointUrl]; + const rpcServiceConfigurations = availableEndpointUrls.map((endpointUrl) => ({ + ...getRpcServiceOptions(endpointUrl), + endpointUrl, + logger, + })); + + /** + * Extracts the error from Cockatiel's `FailureReason` type received in + * circuit breaker event handlers. + * + * The `FailureReason` object can have two possible shapes: + * - `{ error: Error }` - When the RPC service throws an error (the common + * case for RPC failures). + * - `{ value: T }` - When the RPC service returns a value that the retry + * filter policy considers a failure. + * + * @param value - The event data object from the circuit breaker event + * listener (after destructuring known properties like `endpointUrl`). This + * represents Cockatiel's `FailureReason` type. + * @returns The error or failure value, or `undefined` if neither property + * exists (which shouldn't happen in practice unless the circuit breaker is + * manually isolated). + */ + const getError = ( + value: CockatielFailureReason | Record, + ) => { + if ('error' in value) { + return value.error; + } else if ('value' in value) { + return value.value; + } + return undefined; + }; + + const rpcServiceChain = new RpcServiceChain([ + rpcServiceConfigurations[0], + ...rpcServiceConfigurations.slice(1), + ]); + + rpcServiceChain.onBreak((data) => { + const error = getError(data); + + if (error === undefined) { + // This error shouldn't happen in practice because we never call `.isolate` + // on the circuit breaker policy, but we need to appease TypeScript. + throw new Error('Could not make request to endpoint.'); + } + + messenger.publish('NetworkController:rpcEndpointChainUnavailable', { + chainId: configuration.chainId, + networkClientId: id, + error, + }); + }); + + rpcServiceChain.onServiceBreak( + ({ + endpointUrl, + primaryEndpointUrl: primaryEndpointUrlFromEvent, + ...rest + }) => { + const error = getError(rest); + + if (error === undefined) { + // This error shouldn't happen in practice because we never call `.isolate` + // on the circuit breaker policy, but we need to appease TypeScript. + throw new Error('Could not make request to endpoint.'); + } + + messenger.publish('NetworkController:rpcEndpointUnavailable', { + chainId: configuration.chainId, + networkClientId: id, + primaryEndpointUrl: primaryEndpointUrlFromEvent, + endpointUrl, + error, + }); + }, + ); + + rpcServiceChain.onDegraded((data) => { + const error = getError(data); + messenger.publish('NetworkController:rpcEndpointChainDegraded', { + chainId: configuration.chainId, + networkClientId: id, + error, + }); + }); + + rpcServiceChain.onServiceDegraded( + ({ + endpointUrl, + primaryEndpointUrl: primaryEndpointUrlFromEvent, + ...rest + }) => { + const error = getError(rest); + messenger.publish('NetworkController:rpcEndpointDegraded', { + chainId: configuration.chainId, + networkClientId: id, + primaryEndpointUrl: primaryEndpointUrlFromEvent, + endpointUrl, + error, + }); + }, + ); + + rpcServiceChain.onAvailable(() => { + messenger.publish('NetworkController:rpcEndpointChainAvailable', { + chainId: configuration.chainId, + networkClientId: id, + }); + }); + + rpcServiceChain.onServiceRetry( + ({ + attempt, + endpointUrl, + primaryEndpointUrl: primaryEndpointUrlFromEvent, + }) => { + messenger.publish('NetworkController:rpcEndpointRetried', { + chainId: configuration.chainId, + networkClientId: id, + primaryEndpointUrl: primaryEndpointUrlFromEvent, + endpointUrl, + attempt, + }); + }, + ); + + return rpcServiceChain; +} + /** * Create the block tracker for the network. * diff --git a/packages/network-controller/src/index.ts b/packages/network-controller/src/index.ts index 96d93fb02d9..98153162fe7 100644 --- a/packages/network-controller/src/index.ts +++ b/packages/network-controller/src/index.ts @@ -36,9 +36,12 @@ export type { NetworkControllerActions, NetworkControllerMessenger, NetworkControllerOptions, + NetworkControllerRpcEndpointChainUnavailableEvent, NetworkControllerRpcEndpointUnavailableEvent, + NetworkControllerRpcEndpointChainDegradedEvent, NetworkControllerRpcEndpointDegradedEvent, - NetworkControllerRpcEndpointRequestRetriedEvent, + NetworkControllerRpcEndpointChainAvailableEvent, + NetworkControllerRpcEndpointRetriedEvent, } from './NetworkController'; export { getDefaultNetworkControllerState, diff --git a/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts index 3a2c31bfd55..880e5ed7092 100644 --- a/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts @@ -1,17 +1,43 @@ +import { + DEFAULT_CIRCUIT_BREAK_DURATION, + DEFAULT_DEGRADED_THRESHOLD, + HttpError, +} from '@metamask/controller-utils'; import { errorCodes } from '@metamask/rpc-errors'; import nock from 'nock'; import { useFakeTimers } from 'sinon'; import type { SinonFakeTimers } from 'sinon'; +import { + DEFAULT_MAX_CONSECUTIVE_FAILURES, + DEFAULT_MAX_RETRIES, +} from './rpc-service'; import { RpcServiceChain } from './rpc-service-chain'; -const RESOURCE_UNAVAILABLE_ERROR = expect.objectContaining({ - code: errorCodes.rpc.resourceUnavailable, - message: 'RPC endpoint not found or unavailable.', - data: { - httpStatus: 503, - }, -}); +/** + * The number of fetch requests made for a single request to an RPC service, using default max + * retry attempts. + */ +const DEFAULT_REQUEST_ATTEMPTS = 1 + DEFAULT_MAX_RETRIES; + +/** + * Number of attempts required to break the circuit of an RPC service using default retry attempts + * and max consecutive failures. + * + * Note: This calculation and later ones assume that there is no remainder. + */ +const DEFAULT_RPC_SERVICE_ATTEMPTS_UNTIL_BREAK = + DEFAULT_MAX_CONSECUTIVE_FAILURES / DEFAULT_REQUEST_ATTEMPTS; + +/** + * Number of attempts required to break the circuit of an RPC service chain (with a single + * failover) that uses default retry attempts and max consecutive failures. + * + * The value is one less than double the number of attempts needed to break a single circuit + * because on failure of the primary, the request gets forwarded to the failover immediately. + */ +const DEFAULT_RPC_CHAIN_ATTEMPTS_UNTIL_BREAK = + 2 * DEFAULT_RPC_SERVICE_ATTEMPTS_UNTIL_BREAK - 1; describe('RpcServiceChain', () => { let clock: SinonFakeTimers; @@ -24,7 +50,7 @@ describe('RpcServiceChain', () => { clock.restore(); }); - describe('onRetry', () => { + describe('onServiceRetry', () => { it('returns a listener which can be disposed', () => { const rpcServiceChain = new RpcServiceChain([ { @@ -34,10 +60,10 @@ describe('RpcServiceChain', () => { }, ]); - const onRetryListener = rpcServiceChain.onRetry(() => { + const onServiceRetryListener = rpcServiceChain.onServiceRetry(() => { // do whatever }); - expect(onRetryListener.dispose()).toBeUndefined(); + expect(onServiceRetryListener.dispose()).toBeUndefined(); }); }); @@ -58,6 +84,23 @@ describe('RpcServiceChain', () => { }); }); + describe('onServiceBreak', () => { + it('returns a listener which can be disposed', () => { + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }, + ]); + + const onServiceBreakListener = rpcServiceChain.onServiceBreak(() => { + // do whatever + }); + expect(onServiceBreakListener.dispose()).toBeUndefined(); + }); + }); + describe('onDegraded', () => { it('returns a listener which can be disposed', () => { const rpcServiceChain = new RpcServiceChain([ @@ -75,9 +118,45 @@ describe('RpcServiceChain', () => { }); }); + describe('onServiceDegraded', () => { + it('returns a listener which can be disposed', () => { + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }, + ]); + + const onServiceDegradedListener = rpcServiceChain.onServiceDegraded( + () => { + // do whatever + }, + ); + expect(onServiceDegradedListener.dispose()).toBeUndefined(); + }); + }); + + describe('onAvailable', () => { + it('returns a listener which can be disposed', () => { + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }, + ]); + + const onAvailableListener = rpcServiceChain.onAvailable(() => { + // do whatever + }); + expect(onAvailableListener.dispose()).toBeUndefined(); + }); + }); + describe('request', () => { it('returns what the first RPC service in the chain returns, if it succeeds', async () => { - nock('https://first.chain') + nock('https://first.endpoint') .post('/', { id: 1, jsonrpc: '2.0', @@ -94,12 +173,12 @@ describe('RpcServiceChain', () => { { fetch, btoa, - endpointUrl: 'https://first.chain', + endpointUrl: 'https://first.endpoint', }, { fetch, btoa, - endpointUrl: 'https://second.chain', + endpointUrl: 'https://second.endpoint', fetchOptions: { headers: { 'X-Foo': 'Bar', @@ -127,30 +206,24 @@ describe('RpcServiceChain', () => { }); }); - it('uses the other RPC services in the chain as failovers', async () => { - nock('https://first.chain') - .post( - '/', - { - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }, - { - reqheaders: {}, - }, - ) - .times(15) + it('returns what a failover service returns, if the primary is unavailable and the failover is not', async () => { + nock('https://first.endpoint') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) .reply(503); - nock('https://second.chain') + nock('https://second.endpoint') .post('/', { id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [], }) - .times(15) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) .reply(503); nock('https://third.chain') .post('/', { @@ -164,22 +237,17 @@ describe('RpcServiceChain', () => { jsonrpc: '2.0', result: 'ok', }); - + const expectedError = createResourceUnavailableError(503); const rpcServiceChain = new RpcServiceChain([ { fetch, btoa, - endpointUrl: 'https://first.chain', + endpointUrl: 'https://first.endpoint', }, { fetch, btoa, - endpointUrl: 'https://second.chain', - fetchOptions: { - headers: { - 'X-Foo': 'Bar', - }, - }, + endpointUrl: 'https://second.endpoint', }, { fetch, @@ -187,11 +255,8 @@ describe('RpcServiceChain', () => { endpointUrl: 'https://third.chain', }, ]); - rpcServiceChain.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); }); const jsonRpcRequest = { @@ -202,22 +267,22 @@ describe('RpcServiceChain', () => { }; // Retry the first endpoint until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - RESOURCE_UNAVAILABLE_ERROR, + expectedError, ); // Retry the first endpoint again, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - RESOURCE_UNAVAILABLE_ERROR, + expectedError, ); // Retry the first endpoint for a third time, until max retries is hit. // The circuit will break on the last time, and the second endpoint will // be retried, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - RESOURCE_UNAVAILABLE_ERROR, + expectedError, ); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - RESOURCE_UNAVAILABLE_ERROR, + expectedError, ); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. @@ -233,7 +298,7 @@ describe('RpcServiceChain', () => { }); it("allows each RPC service's fetch options to be configured separately, yet passes the fetch options given to request to all of them", async () => { - const firstEndpointScope = nock('https://first.chain', { + const firstEndpointScope = nock('https://first.endpoint', { reqheaders: { 'X-Fizz': 'Buzz', }, @@ -244,11 +309,10 @@ describe('RpcServiceChain', () => { method: 'eth_chainId', params: [], }) - .times(15) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) .reply(503); - const secondEndpointScope = nock('https://second.chain', { + const secondEndpointScope = nock('https://second.endpoint', { reqheaders: { - 'X-Foo': 'Bar', 'X-Fizz': 'Buzz', }, }) @@ -258,11 +322,10 @@ describe('RpcServiceChain', () => { method: 'eth_chainId', params: [], }) - .times(15) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) .reply(503); const thirdEndpointScope = nock('https://third.chain', { reqheaders: { - 'X-Foo': 'Bar', 'X-Fizz': 'Buzz', }, }) @@ -277,17 +340,17 @@ describe('RpcServiceChain', () => { jsonrpc: '2.0', result: 'ok', }); - + const expectedError = createResourceUnavailableError(503); const rpcServiceChain = new RpcServiceChain([ { fetch, btoa, - endpointUrl: 'https://first.chain', + endpointUrl: 'https://first.endpoint', }, { fetch, btoa, - endpointUrl: 'https://second.chain', + endpointUrl: 'https://second.endpoint', fetchOptions: { headers: { 'X-Foo': 'Bar', @@ -303,11 +366,8 @@ describe('RpcServiceChain', () => { }, }, ]); - rpcServiceChain.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); }); const jsonRpcRequest = { @@ -324,22 +384,22 @@ describe('RpcServiceChain', () => { // Retry the first endpoint until max retries is hit. await expect( rpcServiceChain.request(jsonRpcRequest, fetchOptions), - ).rejects.toThrow(RESOURCE_UNAVAILABLE_ERROR); + ).rejects.toThrow(expectedError); // Retry the first endpoint again, until max retries is hit. await expect( rpcServiceChain.request(jsonRpcRequest, fetchOptions), - ).rejects.toThrow(RESOURCE_UNAVAILABLE_ERROR); + ).rejects.toThrow(expectedError); // Retry the first endpoint for a third time, until max retries is hit. // The circuit will break on the last time, and the second endpoint will // be retried, until max retries is hit. await expect( rpcServiceChain.request(jsonRpcRequest, fetchOptions), - ).rejects.toThrow(RESOURCE_UNAVAILABLE_ERROR); + ).rejects.toThrow(expectedError); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. await expect( rpcServiceChain.request(jsonRpcRequest, fetchOptions), - ).rejects.toThrow(RESOURCE_UNAVAILABLE_ERROR); + ).rejects.toThrow(expectedError); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. // The circuit will break on the last time, and the third endpoint will @@ -351,26 +411,79 @@ describe('RpcServiceChain', () => { expect(thirdEndpointScope.isDone()).toBe(true); }); - it('calls onRetry each time an RPC service in the chain retries its request', async () => { - nock('https://first.chain') + it("throws a custom error if a request is attempted while a service's circuit is open", async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl, + }, + ]); + const onBreakListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint for a third time, until max retries is hit. + // The circuit will break on the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Attempt the endpoint again. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'RPC endpoint returned too many errors', + ); + }); + + it('calls onServiceRetry each time an RPC service in the chain retries its request', async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + const tertiaryEndpointUrl = 'https://third.chain'; + nock(primaryEndpointUrl) .post('/', { id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [], }) - .times(15) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) .reply(503); - nock('https://second.chain') + nock(secondaryEndpointUrl) .post('/', { id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [], }) - .times(15) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) .reply(503); - nock('https://third.chain') + nock(tertiaryEndpointUrl) .post('/', { id: 1, jsonrpc: '2.0', @@ -382,17 +495,18 @@ describe('RpcServiceChain', () => { jsonrpc: '2.0', result: 'ok', }); - + const expectedError = createResourceUnavailableError(503); + const expectedRetryError = new HttpError(503); const rpcServiceChain = new RpcServiceChain([ { fetch, btoa, - endpointUrl: 'https://first.chain', + endpointUrl: primaryEndpointUrl, }, { fetch, btoa, - endpointUrl: 'https://second.chain', + endpointUrl: secondaryEndpointUrl, fetchOptions: { headers: { 'X-Foo': 'Bar', @@ -402,19 +516,13 @@ describe('RpcServiceChain', () => { { fetch, btoa, - endpointUrl: 'https://third.chain', + endpointUrl: tertiaryEndpointUrl, }, ]); - const onRetryListener = jest.fn< - ReturnType[0]>, - Parameters[0]> - >(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); + const onServiceRetryListener = jest.fn(() => { + clock.next(); }); - rpcServiceChain.onRetry(onRetryListener); + rpcServiceChain.onServiceRetry(onServiceRetryListener); const jsonRpcRequest = { id: 1, @@ -424,22 +532,22 @@ describe('RpcServiceChain', () => { }; // Retry the first endpoint until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - RESOURCE_UNAVAILABLE_ERROR, + expectedError, ); // Retry the first endpoint again, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - RESOURCE_UNAVAILABLE_ERROR, + expectedError, ); // Retry the first endpoint for a third time, until max retries is hit. // The circuit will break on the last time, and the second endpoint will // be retried, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - RESOURCE_UNAVAILABLE_ERROR, + expectedError, ); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - RESOURCE_UNAVAILABLE_ERROR, + expectedError, ); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. @@ -447,85 +555,119 @@ describe('RpcServiceChain', () => { // be hit. This is finally a success. await rpcServiceChain.request(jsonRpcRequest); - const onRetryListenerCallCountsByEndpointUrl = - onRetryListener.mock.calls.reduce( - (memo, call) => { - const { endpointUrl } = call[0]; - memo[endpointUrl] = (memo[endpointUrl] ?? 0) + 1; - return memo; - }, - {} as Record, - ); - - expect(onRetryListenerCallCountsByEndpointUrl).toStrictEqual({ - 'https://first.chain/': 12, - 'https://second.chain/': 12, - }); + for (let attempt = 0; attempt < 24; attempt++) { + expect(onServiceRetryListener).toHaveBeenNthCalledWith(attempt + 1, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: + attempt >= 12 + ? `${secondaryEndpointUrl}/` + : `${primaryEndpointUrl}/`, + attempt: (attempt % 4) + 1, + delay: expect.any(Number), + error: expectedRetryError, + }); + } }); - it('calls onBreak each time the underlying circuit for each RPC service in the chain breaks', async () => { - nock('https://first.chain') + it('does not call onBreak if the primary service circuit breaks and the request to its failover fails but its circuit has not broken yet', async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) .post('/', { id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [], }) - .times(15) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) .reply(503); - nock('https://second.chain') + nock(secondaryEndpointUrl) .post('/', { id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [], }) - .times(15) - .reply(503); - nock('https://third.chain') + .reply(500); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + endpointUrl: secondaryEndpointUrl, + }, + ]); + const onBreakListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + createResourceUnavailableError(503), + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + createResourceUnavailableError(503), + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be hit (unsuccessfully). + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + createResourceUnavailableError(500), + ); + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + + it("calls onBreak when all of the RPC services' circuits have broken", async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) .post('/', { id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [], }) - .reply(200, { + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { id: 1, jsonrpc: '2.0', - result: 'ok', - }); - + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + const expectedError = createResourceUnavailableError(503); const rpcServiceChain = new RpcServiceChain([ { fetch, btoa, - endpointUrl: 'https://first.chain', - }, - { - fetch, - btoa, - endpointUrl: 'https://second.chain', - fetchOptions: { - headers: { - 'X-Foo': 'Bar', - }, - }, + endpointUrl: primaryEndpointUrl, }, { fetch, btoa, - endpointUrl: 'https://third.chain', + endpointUrl: secondaryEndpointUrl, }, ]); - const onBreakListener = jest.fn< - ReturnType[0]>, - Parameters[0]> - >(); - rpcServiceChain.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); + const onBreakListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); }); rpcServiceChain.onBreak(onBreakListener); @@ -537,64 +679,146 @@ describe('RpcServiceChain', () => { }; // Retry the first endpoint until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - RESOURCE_UNAVAILABLE_ERROR, + expectedError, ); // Retry the first endpoint again, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - RESOURCE_UNAVAILABLE_ERROR, + expectedError, ); // Retry the first endpoint for a third time, until max retries is hit. // The circuit will break on the last time, and the second endpoint will // be retried, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - RESOURCE_UNAVAILABLE_ERROR, + expectedError, ); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - RESOURCE_UNAVAILABLE_ERROR, + expectedError, ); // Try the first endpoint, see that the circuit is broken, and retry the - // second endpoint, until max retries is hit. - // The circuit will break on the last time, and the third endpoint will - // be hit. This is finally a success. - await rpcServiceChain.request(jsonRpcRequest); - - expect(onBreakListener).toHaveBeenCalledTimes(2); - expect(onBreakListener).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - endpointUrl: 'https://first.chain/', - }), - ); - expect(onBreakListener).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - endpointUrl: 'https://second.chain/', - }), + // second endpoint, until max retries is hit. The circuit will break on + // the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, ); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ + error: new Error("Fetch failed with status '503'"), + }); }); - it('calls onDegraded each time an RPC service in the chain gives up before the circuit breaks or responds successfully but slowly', async () => { - nock('https://first.chain') + it("calls onBreak again if all services' circuits break, the primary service responds successfully, and all services' circuits break again", async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + nock(primaryEndpointUrl) .post('/', { id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [], }) - .times(15) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) .reply(503); - nock('https://second.chain') + nock(secondaryEndpointUrl) .post('/', { id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [], }) - .times(15) + .times(30) .reply(503); - nock('https://third.chain') + const expectedError = createResourceUnavailableError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + endpointUrl: secondaryEndpointUrl, + }, + ]); + const onBreakListener = jest.fn(); + const onAvailableListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onBreak(onBreakListener); + rpcServiceChain.onAvailable(onAvailableListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until its circuit breaks, then retry the + // second endpoint until *its* circuit breaks. + for (let i = 0; i < DEFAULT_RPC_CHAIN_ATTEMPTS_UNTIL_BREAK; i++) { + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + } + // Wait until the circuit break duration passes, try the first endpoint + // and see that it succeeds. + clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); + await rpcServiceChain.request(jsonRpcRequest); + // Do it again: retry the first endpoint until its circuit breaks, then + // retry the second endpoint until *its* circuit breaks. + for (let i = 0; i < DEFAULT_RPC_CHAIN_ATTEMPTS_UNTIL_BREAK; i++) { + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + } + + expect(onBreakListener).toHaveBeenCalledTimes(2); + expect(onBreakListener).toHaveBeenNthCalledWith(1, { + error: new Error("Fetch failed with status '503'"), + }); + expect(onBreakListener).toHaveBeenNthCalledWith(2, { + error: new Error("Fetch failed with status '503'"), + }); + }); + + it("calls onBreak again if all services' circuits break, the primary service responds successfully but slowly, and all circuits break again", async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(primaryEndpointUrl) .post('/', { id: 1, jsonrpc: '2.0', @@ -602,47 +826,49 @@ describe('RpcServiceChain', () => { params: [], }) .reply(200, () => { - clock.tick(6000); + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); return { id: 1, jsonrpc: '2.0', result: '0x1', }; }); - + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(30) + .reply(503); + const expectedError = createResourceUnavailableError(503); const rpcServiceChain = new RpcServiceChain([ { fetch, btoa, - endpointUrl: 'https://first.chain', - }, - { - fetch, - btoa, - endpointUrl: 'https://second.chain', - fetchOptions: { - headers: { - 'X-Foo': 'Bar', - }, - }, + endpointUrl: primaryEndpointUrl, }, { fetch, btoa, - endpointUrl: 'https://third.chain', + endpointUrl: secondaryEndpointUrl, }, ]); - const onDegradedListener = jest.fn< - ReturnType[0]>, - Parameters[0]> - >(); - rpcServiceChain.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); + const onBreakListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); }); - rpcServiceChain.onDegraded(onDegradedListener); + rpcServiceChain.onBreak(onBreakListener); const jsonRpcRequest = { id: 1, @@ -650,46 +876,1224 @@ describe('RpcServiceChain', () => { method: 'eth_chainId', params: [], }; - // Retry the first endpoint until max retries is hit. - await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - RESOURCE_UNAVAILABLE_ERROR, - ); - // Retry the first endpoint again, until max retries is hit. - await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - RESOURCE_UNAVAILABLE_ERROR, - ); - // Retry the first endpoint for a third time, until max retries is hit. - // The circuit will break on the last time, and the second endpoint will - // be retried, until max retries is hit. - await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - RESOURCE_UNAVAILABLE_ERROR, + // Retry the first endpoint until its circuit breaks, then retry the + // second endpoint until *its* circuit breaks. + for (let i = 0; i < DEFAULT_RPC_CHAIN_ATTEMPTS_UNTIL_BREAK; i++) { + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + } + // Wait until the circuit break duration passes, try the first endpoint + // and see that it succeeds. + clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); + await rpcServiceChain.request(jsonRpcRequest); + // Do it again: retry the first endpoint until its circuit breaks, then + // retry the second endpoint until *its* circuit breaks. + for (let i = 0; i < DEFAULT_RPC_CHAIN_ATTEMPTS_UNTIL_BREAK; i++) { + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + } + + expect(onBreakListener).toHaveBeenCalledTimes(2); + expect(onBreakListener).toHaveBeenNthCalledWith(1, { + error: new Error("Fetch failed with status '503'"), + }); + expect(onBreakListener).toHaveBeenNthCalledWith(2, { + error: new Error("Fetch failed with status '503'"), + }); + }); + + it('calls onServiceBreak each time the circuit of an RPC service in the chain breaks', async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + const tertiaryEndpointUrl = 'https://third.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(tertiaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + endpointUrl: secondaryEndpointUrl, + }, + { + fetch, + btoa, + endpointUrl: tertiaryEndpointUrl, + }, + ]); + const onServiceBreakListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onServiceBreak(onServiceBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be hit, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the second endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the second endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the third endpoint will + // be hit, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the third endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the third endpoint for a third time, until max retries is hit. + // The circuit will break on the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + expect(onServiceBreakListener).toHaveBeenCalledTimes(3); + expect(onServiceBreakListener).toHaveBeenNthCalledWith(1, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${primaryEndpointUrl}/`, + error: new Error("Fetch failed with status '503'"), + }); + expect(onServiceBreakListener).toHaveBeenNthCalledWith(2, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${secondaryEndpointUrl}/`, + error: new Error("Fetch failed with status '503'"), + }); + expect(onServiceBreakListener).toHaveBeenNthCalledWith(3, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${tertiaryEndpointUrl}/`, + error: new Error("Fetch failed with status '503'"), + }); + }); + + it("calls onDegraded only once even if a service's maximum number of retries is reached multiple times", async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl, + }, + ]); + const onDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint for a third time, until max retries is hit. + // The circuit will break on the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ + error: expectedDegradedError, + }); + }); + + it('calls onDegraded only once even if the time to complete a request via a service is continually slow', async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(2) + .reply(200, () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl, + }, + ]); + const onDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await rpcServiceChain.request(jsonRpcRequest); + await rpcServiceChain.request(jsonRpcRequest); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({}); + }); + + it('calls onDegraded only once even if a service runs out of retries and then responds successfully but slowly, or vice versa', async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(5) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(5) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl, + }, + ]); + const onDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Try the endpoint again, and see that it succeeds. + await rpcServiceChain.request(jsonRpcRequest); + // Retry the endpoint again until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ + error: expectedDegradedError, + }); + }); + + it("does not call onDegraded again when the primary service's circuit breaks and its failover responds successfully but slowly", async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + endpointUrl: secondaryEndpointUrl, + }, + ]); + const onBreakListener = jest.fn(); + const onDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onBreak(onBreakListener); + rpcServiceChain.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be hit, albeit slowly. + await rpcServiceChain.request(jsonRpcRequest); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ + error: expectedDegradedError, + }); + }); + + it("calls onDegraded again when a service's underlying circuit breaks, and then after waiting, the service responds successfully but slowly", async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl, + }, + ]); + const onDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint for a third time, until max retries is hit. + // The circuit will break on the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Wait until the circuit break duration passes, try the endpoint again, + // and see that it succeeds, but slowly. + clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); + await rpcServiceChain.request(jsonRpcRequest); + + expect(onDegradedListener).toHaveBeenCalledTimes(2); + expect(onDegradedListener).toHaveBeenNthCalledWith(1, { + error: expectedDegradedError, + }); + expect(onDegradedListener).toHaveBeenNthCalledWith(2, {}); + }); + + it("calls onDegraded again when a failover service's underlying circuit breaks, and then after waiting, the primary responds successfully but slowly", async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + endpointUrl: secondaryEndpointUrl, + }, + ]); + const onDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be hit, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the second endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the second endpoint for a third time, until max retries is hit. + // The circuit will break on the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); + // Hit the first endpoint again, and see that it succeeds, but slowly + await rpcServiceChain.request(jsonRpcRequest); + + expect(onDegradedListener).toHaveBeenCalledTimes(2); + expect(onDegradedListener).toHaveBeenNthCalledWith(1, { + error: expectedDegradedError, + }); + expect(onDegradedListener).toHaveBeenNthCalledWith(2, {}); + }); + + it('calls onServiceDegraded each time a service continually runs out of retries (but before its circuit breaks)', async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl, + }, + ]); + const onServiceDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onServiceDegraded(onServiceDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint for a third time, until max retries is hit. + // The circuit will break on the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + expect(onServiceDegradedListener).toHaveBeenCalledTimes(2); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + error: expectedDegradedError, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + error: expectedDegradedError, + }); + }); + + it('calls onServiceDegraded each time a service continually responds slowly', async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(2) + .reply(200, () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl, + }, + ]); + const onServiceDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onServiceDegraded(onServiceDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await rpcServiceChain.request(jsonRpcRequest); + await rpcServiceChain.request(jsonRpcRequest); + + expect(onServiceDegradedListener).toHaveBeenCalledTimes(2); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + }); + }); + + it('calls onServiceDegraded each time a service runs out of retries and then responds successfully but slowly, or vice versa', async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(5) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(5) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl, + }, + ]); + const onServiceDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onServiceDegraded(onServiceDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Try the endpoint again, and see that it succeeds. + await rpcServiceChain.request(jsonRpcRequest); + // Retry the endpoint again until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + expect(onServiceDegradedListener).toHaveBeenCalledTimes(3); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + error: expectedDegradedError, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + error: expectedDegradedError, + }); + }); + + it("calls onServiceDegraded again when the primary service's circuit breaks and its failover responds successfully but slowly", async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + endpointUrl: secondaryEndpointUrl, + }, + ]); + const onBreakListener = jest.fn(); + const onServiceDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onBreak(onBreakListener); + rpcServiceChain.onServiceDegraded(onServiceDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, ); - // Try the first endpoint, see that the circuit is broken, and retry the - // second endpoint, until max retries is hit. + // Retry the first endpoint again, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - RESOURCE_UNAVAILABLE_ERROR, + expectedError, ); - // Try the first endpoint, see that the circuit is broken, and retry the - // second endpoint, until max retries is hit. - // The circuit will break on the last time, and the third endpoint will - // be hit. This is finally a success. + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be hit, albeit slowly. await rpcServiceChain.request(jsonRpcRequest); - const onDegradedListenerCallCountsByEndpointUrl = - onDegradedListener.mock.calls.reduce( - (memo: Record, call) => { - const { endpointUrl } = call[0]; - memo[endpointUrl] = (memo[endpointUrl] ?? 0) + 1; - return memo; - }, - {}, - ); + expect(onServiceDegradedListener).toHaveBeenCalledTimes(3); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${primaryEndpointUrl}/`, + error: expectedDegradedError, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${primaryEndpointUrl}/`, + error: expectedDegradedError, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${secondaryEndpointUrl}/`, + }); + }); + + it("calls onServiceDegraded again when a service's underlying circuit breaks, and then after waiting, the service responds successfully but slowly", async () => { + const endpointUrl = 'https://first.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl, + }, + ]); + const onServiceDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onServiceDegraded(onServiceDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint for a third time, until max retries is hit. + // The circuit will break on the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Wait until the circuit break duration passes, try the endpoint again, + // and see that it succeeds, but slowly. + clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); + await rpcServiceChain.request(jsonRpcRequest); + + expect(onServiceDegradedListener).toHaveBeenCalledTimes(3); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + error: expectedDegradedError, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + error: expectedDegradedError, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + }); + }); + + it("calls onServiceDegraded again when a failover service's underlying circuit breaks, and then after waiting, the primary responds successfully but slowly", async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + endpointUrl: secondaryEndpointUrl, + }, + ]); + const onServiceDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onServiceDegraded(onServiceDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be hit, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the second endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the second endpoint for a third time, until max retries is hit. + // The circuit will break on the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); + // Hit the first endpoint again, and see that it succeeds, but slowly + await rpcServiceChain.request(jsonRpcRequest); + + expect(onServiceDegradedListener).toHaveBeenCalledTimes(5); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${primaryEndpointUrl}/`, + error: expectedDegradedError, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${primaryEndpointUrl}/`, + error: expectedDegradedError, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${secondaryEndpointUrl}/`, + error: expectedDegradedError, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(4, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${secondaryEndpointUrl}/`, + error: expectedDegradedError, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(5, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${primaryEndpointUrl}/`, + }); + }); + + it('calls onAvailable only once, even if a service continually responds successfully', async () => { + const endpointUrl = 'https://first.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(3) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl, + }, + ]); + const onAvailableListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onAvailable(onAvailableListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await rpcServiceChain.request(jsonRpcRequest); + await rpcServiceChain.request(jsonRpcRequest); + await rpcServiceChain.request(jsonRpcRequest); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + expect(onAvailableListener).toHaveBeenCalledWith({}); + }); + + it("calls onAvailable once, after the primary service's circuit has broken, the request to the failover succeeds", async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + endpointUrl: secondaryEndpointUrl, + }, + ]); + const onAvailableListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); + }); + rpcServiceChain.onAvailable(onAvailableListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + createResourceUnavailableError(503), + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + createResourceUnavailableError(503), + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be hit. + await rpcServiceChain.request(jsonRpcRequest); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + expect(onAvailableListener).toHaveBeenNthCalledWith(1, {}); + }); - expect(onDegradedListenerCallCountsByEndpointUrl).toStrictEqual({ - 'https://first.chain/': 2, - 'https://second.chain/': 2, - 'https://third.chain/': 1, + it('calls onAvailable when a service becomes degraded by responding slowly, and then recovers', async () => { + const endpointUrl = 'https://first.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + endpointUrl, + }, + ]); + const onDegradedListener = jest.fn(); + const onAvailableListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + clock.next(); }); + rpcServiceChain.onDegraded(onDegradedListener); + rpcServiceChain.onAvailable(onAvailableListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await rpcServiceChain.request(jsonRpcRequest); + await rpcServiceChain.request(jsonRpcRequest); + + // Verify degradation occurred after the first (slow) request + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({}); + + // Verify recovery occurred after the second (fast) request + expect(onAvailableListener).toHaveBeenCalledTimes(1); + expect(onAvailableListener).toHaveBeenCalledWith({}); + + // Verify onDegraded was called before onAvailable (degradation then recovery) + expect(onDegradedListener.mock.invocationCallOrder[0]).toBeLessThan( + onAvailableListener.mock.invocationCallOrder[0], + ); }); }); }); + +/** + * Creates a "resource unavailable" RPC error for testing. + * + * @param httpStatus - The HTTP status that the error represents. + * @returns The RPC error. + */ +function createResourceUnavailableError(httpStatus: number) { + return expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus, + }, + }); +} diff --git a/packages/network-controller/src/rpc-service/rpc-service-chain.ts b/packages/network-controller/src/rpc-service/rpc-service-chain.ts index f8e04a69dbc..a2e964f8bf7 100644 --- a/packages/network-controller/src/rpc-service/rpc-service-chain.ts +++ b/packages/network-controller/src/rpc-service/rpc-service-chain.ts @@ -1,3 +1,7 @@ +import { + CircuitState, + CockatielEventEmitter, +} from '@metamask/controller-utils'; import type { Json, JsonRpcParams, @@ -7,18 +11,83 @@ import type { import { RpcService } from './rpc-service'; import type { RpcServiceOptions } from './rpc-service'; -import type { RpcServiceRequestable } from './rpc-service-requestable'; -import type { FetchOptions } from './shared'; +import type { + CockatielEventToEventListenerWithData, + ExcludeCockatielEventData, + ExtractCockatielEventData, + FetchOptions, +} from './shared'; +import { projectLogger, createModuleLogger } from '../logger'; + +const log = createModuleLogger(projectLogger, 'RpcServiceChain'); + +/** + * Statuses that the RPC service chain can be in. + */ +const STATUSES = { + Available: 'available', + Degraded: 'degraded', + Unknown: 'unknown', + Unavailable: 'unavailable', +} as const; /** - * This class constructs a chain of RpcService objects which represent a - * particular network. The first object in the chain is intended to be the - * primary way of reaching the network and the remaining objects are used as - * failovers. + * Statuses that the RPC service chain can be in. */ -export class RpcServiceChain implements RpcServiceRequestable { +type Status = (typeof STATUSES)[keyof typeof STATUSES]; + +/** + * This class constructs and manages requests to a chain of RpcService objects + * which represent RPC endpoints with which to access a particular network. The + * first service in the chain is intended to be the primary way of hitting the + * network and the remaining services are used as failovers. + */ +export class RpcServiceChain { + /** + * The event emitter for the `onAvailable` event. + */ + readonly #onAvailableEventEmitter: CockatielEventEmitter< + ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + > + >; + + /** + * The event emitter for the `onBreak` event. + */ + readonly #onBreakEventEmitter: CockatielEventEmitter< + ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + > + >; + + /** + * The event emitter for the `onDegraded` event. + */ + readonly #onDegradedEventEmitter: CockatielEventEmitter< + ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + > + >; + + /** + * The first RPC service that requests will be sent to. + */ + readonly #primaryService: RpcService; + + /** + * The RPC services in the chain. + */ readonly #services: RpcService[]; + /** + * The status of the RPC service chain. + */ + #status: Status; + /** * Constructs a new RpcServiceChain object. * @@ -27,20 +96,78 @@ export class RpcServiceChain implements RpcServiceRequestable { * {@link RpcServiceOptions}. */ constructor( - rpcServiceConfigurations: Omit[], + rpcServiceConfigurations: [RpcServiceOptions, ...RpcServiceOptions[]], ) { - this.#services = this.#buildRpcServiceChain(rpcServiceConfigurations); + this.#services = rpcServiceConfigurations.map( + (rpcServiceConfiguration) => new RpcService(rpcServiceConfiguration), + ); + this.#primaryService = this.#services[0]; + + this.#status = STATUSES.Unknown; + this.#onBreakEventEmitter = new CockatielEventEmitter< + ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + > + >(); + + this.#onDegradedEventEmitter = new CockatielEventEmitter< + ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + > + >(); + for (const service of this.#services) { + service.onDegraded((data) => { + if (this.#status !== STATUSES.Degraded) { + log('Updating status to "degraded"', data); + this.#status = STATUSES.Degraded; + const { endpointUrl, ...rest } = data; + this.#onDegradedEventEmitter.emit(rest); + } + }); + } + + this.#onAvailableEventEmitter = new CockatielEventEmitter< + ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + > + >(); + for (const service of this.#services) { + service.onAvailable((data) => { + if (this.#status !== STATUSES.Available) { + log('Updating status to "available"', data); + this.#status = STATUSES.Available; + const { endpointUrl, ...rest } = data; + this.#onAvailableEventEmitter.emit(rest); + } + }); + } } /** - * Listens for when any of the RPC services retry a request. + * Calls the provided callback when any of the RPC services is retried. + * + * This is mainly useful for tests. * - * @param listener - The callback to be called when the retry occurs. - * @returns What {@link RpcService.onRetry} returns. + * @param listener - The callback to be called. + * @returns An object with a `dispose` method which can be used to unregister + * the event listener. */ - onRetry(listener: Parameters[0]) { + onServiceRetry( + listener: CockatielEventToEventListenerWithData< + RpcService['onRetry'], + { primaryEndpointUrl: string } + >, + ) { const disposables = this.#services.map((service) => - service.onRetry(listener), + service.onRetry((data) => { + listener({ + ...data, + primaryEndpointUrl: this.#primaryService.endpointUrl.toString(), + }); + }), ); return { @@ -51,15 +178,53 @@ export class RpcServiceChain implements RpcServiceRequestable { } /** - * Listens for when any of the RPC services retry the request too many times - * in a row. + * Calls the provided callback only when the maximum number of failed + * consecutive attempts to receive a 2xx response has been reached for all + * RPC services in the chain, and all services' underlying circuits have + * broken. + * + * The callback will not be called if a service's circuit breaks but its + * failover does not. Use `onServiceBreak` if you'd like a lower level of + * granularity. + * + * @param listener - The callback to be called. + * @returns An object with a `dispose` method which can be used to unregister + * the callback. + */ + onBreak( + listener: ( + data: ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + >, + ) => void, + ) { + return this.#onBreakEventEmitter.addListener(listener); + } + + /** + * Calls the provided callback each time when, for *any* of the RPC services + * in this chain, the maximum number of failed consecutive attempts to receive + * a 2xx response has been reached and the underlying circuit has broken. A + * more granular version of `onBreak`. * - * @param listener - The callback to be called when the retry occurs. - * @returns What {@link RpcService.onBreak} returns. + * @param listener - The callback to be called. + * @returns An object with a `dispose` method which can be used to unregister + * the callback. */ - onBreak(listener: Parameters[0]) { + onServiceBreak( + listener: CockatielEventToEventListenerWithData< + RpcService['onBreak'], + { primaryEndpointUrl: string } + >, + ) { const disposables = this.#services.map((service) => - service.onBreak(listener), + service.onBreak((data) => { + listener({ + ...data, + primaryEndpointUrl: this.#primaryService.endpointUrl.toString(), + }); + }), ); return { @@ -70,14 +235,72 @@ export class RpcServiceChain implements RpcServiceRequestable { } /** - * Listens for when any of the RPC services send a slow request. + * Calls the provided callback if no requests have been initiated yet or + * all requests to RPC services in this chain have responded successfully in a + * timely fashion, and then one of the two conditions apply: + * + * 1. When a retriable error is encountered making a request to an RPC + * service, and the request is retried until a set maximum is reached. + * 2. When a RPC service responds successfully, but the request takes longer + * than a set number of seconds to complete. + * + * Note that the callback will be called even if there are local connectivity + * issues which prevent requests from being initiated. This is intentional. + * + * Also note this callback will only be called if the RPC service chain as a + * whole is in a "degraded" state, and will then only be called once (e.g., it + * will not be called if a failover service falls into a degraded state, then + * the primary comes back online, but it is slow). Use `onServiceDegraded` if + * you'd like a lower level of granularity. + * + * @param listener - The callback to be called. + * @returns An object with a `dispose` method which can be used to unregister + * the callback. + */ + onDegraded( + listener: ( + data: ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + >, + ) => void, + ) { + return this.#onDegradedEventEmitter.addListener(listener); + } + + /** + * Calls the provided callback each time one of the two conditions apply: * - * @param listener - The callback to be called when the retry occurs. - * @returns What {@link RpcService.onRetry} returns. + * 1. When a retriable error is encountered making a request to an RPC + * service, and the request is retried until a set maximum is reached. + * 2. When a RPC service responds successfully, but the request takes longer + * than a set number of seconds to complete. + * + * Note that the callback will be called even if there are local connectivity + * issues which prevent requests from being initiated. This is intentional. + * + * This is a more granular version of `onDegraded`. The callback will be + * called for each slow request to an RPC service. It may also be called again + * if a failover service falls into a degraded state, then the primary comes + * back online, but it is slow. + * + * @param listener - The callback to be called. + * @returns An object with a `dispose` method which can be used to unregister + * the callback. */ - onDegraded(listener: Parameters[0]) { + onServiceDegraded( + listener: CockatielEventToEventListenerWithData< + RpcService['onDegraded'], + { primaryEndpointUrl: string } + >, + ) { const disposables = this.#services.map((service) => - service.onDegraded(listener), + service.onDegraded((data) => { + listener({ + ...data, + primaryEndpointUrl: this.#primaryService.endpointUrl.toString(), + }); + }), ); return { @@ -88,8 +311,35 @@ export class RpcServiceChain implements RpcServiceRequestable { } /** - * Makes a request to the first RPC service in the chain. If this service is - * down, then the request is forwarded to the next service in the chain, etc. + * Calls the provided callback in one of the following two conditions: + * + * 1. The first time that a 2xx request is made to any of the RPC services in + * this chain. + * 2. When requests to any the failover RPC services in this chain were + * failing such that they were degraded or their underyling circuits broke, + * but the first request to the primary succeeds again. + * + * Note this callback will only be called if the RPC service chain as a whole + * is in an "available" state. + * + * @param listener - The callback to be called. + * @returns An object with a `dispose` method which can be used to unregister + * the callback. + */ + onAvailable( + listener: ( + data: ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + >, + ) => void, + ) { + return this.#onAvailableEventEmitter.addListener(listener); + } + + /** + * Uses the RPC services in the chain to make a request, using each service + * after the first as a fallback to the previous one as necessary. * * This overload is specifically designed for `eth_getBlockByNumber`, which * can return a `result` of `null` despite an expected `Result` being @@ -113,8 +363,8 @@ export class RpcServiceChain implements RpcServiceRequestable { ): Promise | JsonRpcResponse>; /** - * Makes a request to the first RPC service in the chain. If this service is - * down, then the request is forwarded to the next service in the chain, etc. + * Uses the RPC services in the chain to make a request, using each service + * after the first as a fallback to the previous one as necessary. * * This overload is designed for all RPC methods except for * `eth_getBlockByNumber`, which are expected to return a `result` of the @@ -139,31 +389,92 @@ export class RpcServiceChain implements RpcServiceRequestable { jsonRpcRequest: Readonly>, fetchOptions: FetchOptions = {}, ): Promise> { - return this.#services[0].request(jsonRpcRequest, fetchOptions); - } + // Start with the primary (first) service and switch to failovers as the + // need arises. This is a bit confusing, so keep reading for more on how + // this works. - /** - * Constructs the chain of RPC services. The second RPC service is - * configured as the failover for the first, the third service is - * configured as the failover for the second, etc. - * - * @param rpcServiceConfigurations - The options for the RPC services that - * you want to construct. Each object in this array is the same as - * {@link RpcServiceOptions}. - * @returns The constructed chain of RPC services. - */ - #buildRpcServiceChain( - rpcServiceConfigurations: Omit[], - ): RpcService[] { - return [...rpcServiceConfigurations] - .reverse() - .reduce((workingServices: RpcService[], serviceConfiguration, index) => { - const failoverService = index > 0 ? workingServices[0] : undefined; - const service = new RpcService({ - ...serviceConfiguration, - failoverService, - }); - return [service, ...workingServices]; - }, []); + let availableServiceIndex: number | undefined; + let response: JsonRpcResponse | undefined; + + for (const [i, service] of this.#services.entries()) { + log(`Trying service #${i + 1}...`); + const previousCircuitState = service.getCircuitState(); + + try { + // Try making the request through the service. + response = await service.request( + jsonRpcRequest, + fetchOptions, + ); + log('Service successfully received request.'); + availableServiceIndex = i; + break; + } catch (error) { + // Oops, that didn't work. + // Capture this error so that we can handle it later. + + const { lastError } = service; + const isCircuitOpen = service.getCircuitState() === CircuitState.Open; + + log('Service failed! error =', error, 'lastError = ', lastError); + + if (isCircuitOpen) { + if (i < this.#services.length - 1) { + log( + "This service's circuit is open. Proceeding to next service...", + ); + continue; + } + + if ( + previousCircuitState !== CircuitState.Open && + this.#status !== STATUSES.Unavailable && + lastError !== undefined + ) { + // If the service's circuit just broke and it's the last one in the + // chain, then trigger the onBreak event. (But if for some reason we + // have already done this, then don't do it.) + log( + 'This service\'s circuit just opened and it is the last service. Updating status to "unavailable" and triggering onBreak.', + ); + this.#status = STATUSES.Unavailable; + this.#onBreakEventEmitter.emit({ + error: lastError, + }); + } + } + + // The service failed, and we throw whatever the error is. The calling + // code can try again if it so desires. + log( + `${isCircuitOpen ? '' : "This service's circuit is closed. "}Re-throwing error.`, + ); + throw error; + } + } + + if (response) { + // If one of the services is available, reset all of the circuits of the + // following services. If we didn't do this and the service became + // unavailable in the future, and any of the failovers' circuits were + // open (due to previous failures), we would receive a "circuit broken" + // error when we attempted to divert traffic to the failovers again. + // + if (availableServiceIndex !== undefined) { + for (const [i, service] of [...this.#services.entries()].slice( + availableServiceIndex + 1, + )) { + log(`Resetting policy for service #${i + 1}.`); + service.resetPolicy(); + } + } + + return response; + } + + // The only way we can end up here is if there are no services to loop over. + // That is not possible due to the types on the constructor, but TypeScript + // doesn't know this, so we have to appease it. + throw new Error('Nothing to return'); } } diff --git a/packages/network-controller/src/rpc-service/rpc-service-requestable.ts b/packages/network-controller/src/rpc-service/rpc-service-requestable.ts index c3dbcb4a495..85ff60e176c 100644 --- a/packages/network-controller/src/rpc-service/rpc-service-requestable.ts +++ b/packages/network-controller/src/rpc-service/rpc-service-requestable.ts @@ -6,7 +6,13 @@ import type { JsonRpcResponse, } from '@metamask/utils'; -import type { AddToCockatielEventData, FetchOptions } from './shared'; +import type { + CockatielEventToEventListenerWithData, + ExcludeCockatielEventData, + ExtendCockatielEventData, + ExtractCockatielEventData, + FetchOptions, +} from './shared'; /** * The interface for a service class responsible for making a request to a @@ -22,8 +28,8 @@ export type RpcServiceRequestable = { * @see {@link createServicePolicy} */ onRetry( - listener: AddToCockatielEventData< - Parameters[0], + listener: CockatielEventToEventListenerWithData< + ServicePolicy['onRetry'], { endpointUrl: string } >, ): ReturnType; @@ -37,10 +43,15 @@ export type RpcServiceRequestable = { * @see {@link createServicePolicy} */ onBreak( - listener: AddToCockatielEventData< - Parameters[0], - { endpointUrl: string } - >, + listener: ( + data: ExcludeCockatielEventData< + ExtendCockatielEventData< + ExtractCockatielEventData, + { endpointUrl: string } + >, + 'isolated' + >, + ) => void, ): ReturnType; /** @@ -52,12 +63,26 @@ export type RpcServiceRequestable = { * @see {@link createServicePolicy} */ onDegraded( - listener: AddToCockatielEventData< - Parameters[0], + listener: CockatielEventToEventListenerWithData< + ServicePolicy['onDegraded'], { endpointUrl: string } >, ): ReturnType; + /** + * Listens for when the policy underlying this RPC service is available. + * + * @param listener - The callback to be called when the request is available. + * @returns What {@link ServicePolicy.onDegraded} returns. + * @see {@link createServicePolicy} + */ + onAvailable( + listener: CockatielEventToEventListenerWithData< + ServicePolicy['onAvailable'], + { endpointUrl: string } + >, + ): ReturnType; + /** * Makes a request to the target. */ diff --git a/packages/network-controller/src/rpc-service/rpc-service.test.ts b/packages/network-controller/src/rpc-service/rpc-service.test.ts index 6faaa7799f3..18a96e4d070 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.test.ts @@ -1,14 +1,21 @@ -import { HttpError } from '@metamask/controller-utils'; +import { + DEFAULT_CIRCUIT_BREAK_DURATION, + DEFAULT_DEGRADED_THRESHOLD, + HttpError, +} from '@metamask/controller-utils'; import { errorCodes } from '@metamask/rpc-errors'; +import { CircuitState } from 'cockatiel'; import deepFreeze from 'deep-freeze-strict'; import nock from 'nock'; import { FetchError } from 'node-fetch'; import { useFakeTimers } from 'sinon'; import type { SinonFakeTimers } from 'sinon'; -import type { AbstractRpcService } from './abstract-rpc-service'; -import { CUSTOM_RPC_ERRORS, RpcService } from './rpc-service'; -import { DEFAULT_CIRCUIT_BREAK_DURATION } from '../../../controller-utils/src/create-service-policy'; +import { + CUSTOM_RPC_ERRORS, + DEFAULT_MAX_RETRIES, + RpcService, +} from './rpc-service'; describe('RpcService', () => { let clock: SinonFakeTimers; @@ -21,6 +28,320 @@ describe('RpcService', () => { clock.restore(); }); + describe('resetPolicy', () => { + it('resets the state of the circuit to "closed"', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + service.onRetry(() => { + clock.next(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Get through the first two rounds of retries + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + expect(service.getCircuitState()).toBe(CircuitState.Open); + + service.resetPolicy(); + + expect(service.getCircuitState()).toBe(CircuitState.Closed); + }); + + it('allows making a successful request to the service if its circuit has broken', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + service.onRetry(() => { + clock.next(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Get through the first two rounds of retries + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + service.resetPolicy(); + + expect(await service.request(jsonRpcRequest)).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + }); + + it('calls onAvailable listeners if the service was executed successfully, its circuit broke, it was reset, and executes successfully again', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + const onAvailableListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + service.onRetry(() => { + clock.next(); + }); + service.onAvailable(onAvailableListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + + // Make a successful requst + await service.request(jsonRpcRequest); + expect(onAvailableListener).toHaveBeenCalledTimes(1); + + // Get through the first two rounds of retries + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + service.resetPolicy(); + + // Make another successful requst + await service.request(jsonRpcRequest); + expect(onAvailableListener).toHaveBeenCalledTimes(2); + }); + + it('allows making an unsuccessful request to the service if its circuit has broken', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(500); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + service.onRetry(() => { + clock.next(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Get through the first two rounds of retries + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + service.resetPolicy(); + + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + 'RPC endpoint not found or unavailable', + ); + }); + + it('does not call onBreak listeners', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(500); + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + service.onRetry(() => { + clock.next(); + }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + + // Get through the first two rounds of retries + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + expect(onBreakListener).toHaveBeenCalledTimes(1); + + service.resetPolicy(); + expect(onBreakListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCircuitState', () => { + it('returns the state of the underlying circuit', async () => { + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl).post('/', jsonRpcRequest).times(15).reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(500); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + service.onRetry(() => { + clock.next(); + }); + + expect(service.getCircuitState()).toBe(CircuitState.Closed); + + // Retry until we break the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + expect(service.getCircuitState()).toBe(CircuitState.Open); + + clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); + const promise = ignoreRejection(service.request(jsonRpcRequest)); + expect(service.getCircuitState()).toBe(CircuitState.HalfOpen); + await promise; + expect(service.getCircuitState()).toBe(CircuitState.Open); + }); + }); + describe('request', () => { // NOTE: Keep this list synced with CONNECTION_ERRORS describe.each([ @@ -61,7 +382,7 @@ describe('RpcService', () => { message: 'terminated', }, ])( - `if making the request throws the $message error`, + `if making the request throws the "$message" error`, ({ constructorName, message }) => { let error; switch (constructorName) { @@ -83,7 +404,7 @@ describe('RpcService', () => { ); describe.each(['ETIMEDOUT', 'ECONNRESET'])( - 'if making the request throws a %s error', + 'if making the request throws a "%s" error', (errorCode) => { const error = new Error('timed out'); // @ts-expect-error `code` does not exist on the Error type, but is @@ -99,210 +420,42 @@ describe('RpcService', () => { ); describe('if the endpoint URL was not mocked via Nock', () => { - it('re-throws the error without retrying the request', async () => { - const service = new RpcService({ - fetch, - btoa, - endpointUrl: 'https://rpc.example.chain', - }); - - const promise = service.request({ - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }); - await expect(promise).rejects.toThrow('Nock: Disallowed net connect'); - }); - - it('does not forward the request to a failover service if given one', async () => { - const failoverService = buildMockRpcService(); - const service = new RpcService({ - fetch, - btoa, - endpointUrl: 'https://rpc.example.chain', - failoverService, - }); - - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', - params: [], - }; - await ignoreRejection(service.request(jsonRpcRequest)); - expect(failoverService.request).not.toHaveBeenCalled(); - }); - - it('does not call onBreak', async () => { - const onBreakListener = jest.fn(); - const service = new RpcService({ - fetch, - btoa, - endpointUrl: 'https://rpc.example.chain', - }); - service.onBreak(onBreakListener); - - const promise = service.request({ - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }); - await ignoreRejection(promise); - expect(onBreakListener).not.toHaveBeenCalled(); + testsForNonRetriableErrors({ + expectedError: 'Nock: Disallowed net connect', }); }); describe('if the endpoint URL was mocked via Nock, but not the RPC method', () => { - it('re-throws the error without retrying the request', async () => { - const endpointUrl = 'https://rpc.example.chain'; - nock(endpointUrl) - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_incorrectMethod', - params: [], - }) - .reply(500); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - }); - - const promise = service.request({ - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }); - await expect(promise).rejects.toThrow('Nock: No match for request'); - }); - - it('does not forward the request to a failover service if given one', async () => { - const endpointUrl = 'https://rpc.example.chain'; - nock(endpointUrl) - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_incorrectMethod', - params: [], - }) - .reply(500); - const failoverService = buildMockRpcService(); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - failoverService, - }); - - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', - params: [], - }; - await ignoreRejection(service.request(jsonRpcRequest)); - expect(failoverService.request).not.toHaveBeenCalled(); - }); - - it('does not call onBreak', async () => { - const endpointUrl = 'https://rpc.example.chain'; - nock(endpointUrl) - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_incorrectMethod', - params: [], - }) - .reply(500); - const onBreakListener = jest.fn(); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - }); - service.onBreak(onBreakListener); - - const promise = service.request({ - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }); - await ignoreRejection(promise); - expect(onBreakListener).not.toHaveBeenCalled(); + testsForNonRetriableErrors({ + beforeCreateService: ({ endpointUrl }) => { + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_incorrectMethod', + params: [], + }) + .reply(500); + }, + rpcMethod: 'eth_chainId', + expectedError: 'Nock: No match for request', }); }); describe('if making the request throws an unknown error', () => { - it('re-throws the error without retrying the request', async () => { - const error = new Error('oops'); - const mockFetch = jest.fn(() => { - throw error; - }); - const service = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl: 'https://rpc.example.chain', - }); - - const promise = service.request({ - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }); - await expect(promise).rejects.toThrow(error); - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it('does not forward the request to a failover service if given one', async () => { - const error = new Error('oops'); - const mockFetch = jest.fn(() => { - throw error; - }); - const failoverService = buildMockRpcService(); - const service = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl: 'https://rpc.example.chain', - failoverService, - }); - - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', - params: [], - }; - await ignoreRejection(service.request(jsonRpcRequest)); - expect(failoverService.request).not.toHaveBeenCalled(); - }); - - it('does not call onBreak', async () => { - const error = new Error('oops'); - const mockFetch = jest.fn(() => { - throw error; - }); - const onBreakListener = jest.fn(); - const service = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl: 'https://rpc.example.chain', - }); - service.onBreak(onBreakListener); - - const promise = service.request({ - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }); - await ignoreRejection(promise); - expect(onBreakListener).not.toHaveBeenCalled(); + testsForNonRetriableErrors({ + createService: ({ endpointUrl, expectedError }) => { + return new RpcService({ + fetch: () => { + // This error could be anything. + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw expectedError; + }, + btoa, + endpointUrl, + }); + }, + expectedError: new Error('oops'), }); }); @@ -325,374 +478,97 @@ describe('RpcService', () => { ); describe('if the endpoint has a 401 response', () => { - it('throws an unauthorized error without retrying the request', async () => { - const endpointUrl = 'https://rpc.example.chain'; - nock(endpointUrl) - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }) - .reply(401); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - }); - - const promise = service.request({ - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }); - await expect(promise).rejects.toThrow( - expect.objectContaining({ - code: CUSTOM_RPC_ERRORS.unauthorized, - message: 'Unauthorized.', - data: { - httpStatus: 401, - }, - }), - ); - }); - - it('does not forward the request to a failover service if given one', async () => { - const endpointUrl = 'https://rpc.example.chain'; - nock(endpointUrl) - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }) - .reply(401); - const failoverService = buildMockRpcService(); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - failoverService, - }); - - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', - params: [], - }; - await ignoreRejection(service.request(jsonRpcRequest)); - expect(failoverService.request).not.toHaveBeenCalled(); - }); - - it('does not call onBreak', async () => { - const endpointUrl = 'https://rpc.example.chain'; - nock(endpointUrl) - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }) - .reply(429); - const onBreakListener = jest.fn(); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - }); - service.onBreak(onBreakListener); - - const promise = service.request({ - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }); - await ignoreRejection(promise); - expect(onBreakListener).not.toHaveBeenCalled(); - }); - }); - - describe.each([402, 404, 500, 501, 505, 506, 507, 508, 510, 511])( - 'if the endpoint has a %d response', - (httpStatus) => { - it('throws a resource unavailable error without retrying the request', async () => { - const endpointUrl = 'https://rpc.example.chain'; + testsForNonRetriableErrors({ + beforeCreateService: ({ endpointUrl, rpcMethod }) => { nock(endpointUrl) .post('/', { id: 1, jsonrpc: '2.0', - method: 'eth_unknownMethod', + method: rpcMethod, params: [], }) - .reply(httpStatus); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - }); + .reply(401); + }, + expectedError: expect.objectContaining({ + code: CUSTOM_RPC_ERRORS.unauthorized, + message: 'Unauthorized.', + data: { + httpStatus: 401, + }, + }), + }); + }); - const promise = service.request({ - id: 1, - jsonrpc: '2.0', - method: 'eth_unknownMethod', - params: [], - }); - await expect(promise).rejects.toThrow( - expect.objectContaining({ - code: errorCodes.rpc.resourceUnavailable, - message: 'RPC endpoint not found or unavailable.', - data: { - httpStatus, - }, - }), - ); + describe.each([402, 404, 500, 501, 505, 506, 507, 508, 510, 511])( + 'if the endpoint has a %d response', + (httpStatus) => { + testsForNonRetriableErrors({ + beforeCreateService: ({ endpointUrl, rpcMethod }) => { + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(httpStatus); + }, + expectedError: expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus, + }, + }), }); + }, + ); - it('does not forward the request to a failover service if given one', async () => { - const endpointUrl = 'https://rpc.example.chain'; + describe('if the endpoint has a 429 response', () => { + const httpStatus = 429; + + testsForNonRetriableErrors({ + beforeCreateService: ({ endpointUrl, rpcMethod }) => { nock(endpointUrl) .post('/', { id: 1, jsonrpc: '2.0', - method: 'eth_unknownMethod', + method: rpcMethod, params: [], }) .reply(httpStatus); - const failoverService = buildMockRpcService(); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - failoverService, - }); + }, + expectedError: expect.objectContaining({ + code: errorCodes.rpc.limitExceeded, + message: 'Request is being rate limited.', + data: { + httpStatus, + }, + }), + }); + }); - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_unknownMethod', - params: [], - }; - await ignoreRejection(service.request(jsonRpcRequest)); - expect(failoverService.request).not.toHaveBeenCalled(); - }); + describe('when the endpoint has a 4xx response that is not 401, 402, 404, or 429', () => { + const httpStatus = 422; - it('does not call onBreak', async () => { - const endpointUrl = 'https://rpc.example.chain'; + testsForNonRetriableErrors({ + beforeCreateService: ({ endpointUrl, rpcMethod }) => { nock(endpointUrl) .post('/', { id: 1, jsonrpc: '2.0', - method: 'eth_unknownMethod', + method: rpcMethod, params: [], }) .reply(httpStatus); - const onBreakListener = jest.fn(); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - }); - service.onBreak(onBreakListener); - - const promise = service.request({ - id: 1, - jsonrpc: '2.0', - method: 'eth_unknownMethod', - params: [], - }); - await ignoreRejection(promise); - expect(onBreakListener).not.toHaveBeenCalled(); - }); - }, - ); - - describe('if the endpoint has a 429 response', () => { - it('throws a rate-limiting error without retrying the request', async () => { - const httpStatus = 429; - const endpointUrl = 'https://rpc.example.chain'; - nock(endpointUrl) - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }) - .reply(httpStatus); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - }); - - const promise = service.request({ - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }); - await expect(promise).rejects.toThrow( - expect.objectContaining({ - code: errorCodes.rpc.limitExceeded, - message: 'Request is being rate limited.', - data: { - httpStatus, - }, - }), - ); - }); - - it('does not forward the request to a failover service if given one', async () => { - const httpStatus = 429; - const endpointUrl = 'https://rpc.example.chain'; - nock(endpointUrl) - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }) - .reply(httpStatus); - const failoverService = buildMockRpcService(); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - failoverService, - }); - - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', - params: [], - }; - await ignoreRejection(service.request(jsonRpcRequest)); - expect(failoverService.request).not.toHaveBeenCalled(); - }); - - it('does not call onBreak', async () => { - const httpStatus = 429; - const endpointUrl = 'https://rpc.example.chain'; - nock(endpointUrl) - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }) - .reply(httpStatus); - const onBreakListener = jest.fn(); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - }); - service.onBreak(onBreakListener); - - const promise = service.request({ - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }); - await ignoreRejection(promise); - expect(onBreakListener).not.toHaveBeenCalled(); - }); - }); - - describe('when the endpoint has a 4xx response that is not 401, 402, 404, or 429', () => { - const httpStatus = 422; - - it('throws a generic HTTP client error without retrying the request', async () => { - const endpointUrl = 'https://rpc.example.chain'; - nock(endpointUrl) - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }) - .reply(httpStatus); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - }); - - const promise = service.request({ - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }); - await expect(promise).rejects.toThrow( - expect.objectContaining({ - code: CUSTOM_RPC_ERRORS.httpClientError, - message: 'RPC endpoint returned HTTP client error.', - data: { - httpStatus, - }, - }), - ); - }); - - it('does not forward the request to a failover service if given one', async () => { - const endpointUrl = 'https://rpc.example.chain'; - nock(endpointUrl) - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }) - .reply(httpStatus); - const failoverService = buildMockRpcService(); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - failoverService, - }); - - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', - params: [], - }; - await ignoreRejection(service.request(jsonRpcRequest)); - expect(failoverService.request).not.toHaveBeenCalled(); - }); - - it('does not call onBreak', async () => { - const endpointUrl = 'https://rpc.example.chain'; - nock(endpointUrl) - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }) - .reply(httpStatus); - const onBreakListener = jest.fn(); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - }); - service.onBreak(onBreakListener); - - const promise = service.request({ - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }); - await ignoreRejection(promise); - expect(onBreakListener).not.toHaveBeenCalled(); + }, + expectedError: expect.objectContaining({ + code: CUSTOM_RPC_ERRORS.httpClientError, + message: 'RPC endpoint returned HTTP client error.', + data: { + httpStatus, + }, + }), }); }); @@ -1018,7 +894,7 @@ describe('RpcService', () => { params: [], }) .reply(200, () => { - clock.tick(6000); + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); return { id: 1, jsonrpc: '2.0', @@ -1041,6 +917,103 @@ describe('RpcService', () => { }); expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ + endpointUrl: `${endpointUrl}/`, + }); + }); + + it('calls the onAvailable callback the first time a successful request occurs', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const onAvailableListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + service.onAvailable(onAvailableListener); + + await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + expect(onAvailableListener).toHaveBeenCalledWith({ + endpointUrl: `${endpointUrl}/`, + }); + }); + + it('calls the onAvailable callback if the endpoint takes more than 5 seconds to respond and then speeds up again', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const onAvailableListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + service.onAvailable(onAvailableListener); + + await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + expect(onAvailableListener).toHaveBeenCalledWith({ + endpointUrl: `${endpointUrl}/`, + }); }); }); }); @@ -1062,526 +1035,394 @@ async function ignoreRejection( /** * These are tests that exercise logic for cases in which the request cannot be - * made because the `fetch` calls throws a specific error. + * made because some kind of error is thrown, and the request is not retried. * - * @param args - The arguments - * @param args.getClock - A function that returns the Sinon clock, set in - * `beforeEach`. - * @param args.producedError - The error produced when `fetch` is called. + * @param args - The arguments. + * @param args.beforeCreateService - A function that is run before the service + * is created. + * @param args.createService - A function that is run to create the service. + * @param args.endpointUrl - The URL that is hit. + * @param args.rpcMethod - The RPC method that is used. (Defaults to + * `eth_chainId`). * @param args.expectedError - The error that a call to the service's `request` * method is expected to produce. */ -function testsForRetriableFetchErrors({ - getClock, - producedError, +function testsForNonRetriableErrors({ + beforeCreateService = () => { + // do nothing + }, + createService = (args) => { + return new RpcService({ fetch, btoa, endpointUrl: args.endpointUrl }); + }, + endpointUrl = 'https://rpc.example.chain', + rpcMethod = `eth_chainId`, expectedError, }: { - getClock: () => SinonFakeTimers; - producedError: Error; - expectedError: string | jest.Constructable | RegExp | Error; + beforeCreateService?: (args: { + endpointUrl: string; + rpcMethod: string; + }) => void; + createService?: (args: { + endpointUrl: string; + expectedError: string | RegExp | Error | jest.Constructable | undefined; + }) => RpcService; + endpointUrl?: string; + rpcMethod?: string; + expectedError: string | RegExp | Error | jest.Constructable | undefined; }) { - describe('if there is no failover service provided', () => { - it('retries a constantly failing request up to 4 more times before re-throwing the error, if `request` is only called once', async () => { - const clock = getClock(); - const mockFetch = jest.fn(() => { - throw producedError; - }); - const service = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl: 'https://rpc.example.chain', - }); - service.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); - - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', - params: [], - }; - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expectedError, - ); - expect(mockFetch).toHaveBeenCalledTimes(5); - }); + /* eslint-disable jest/require-top-level-describe */ - it('still re-throws the error even after the circuit breaks', async () => { - const clock = getClock(); - const mockFetch = jest.fn(() => { - throw producedError; - }); - const service = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl: 'https://rpc.example.chain', - }); - service.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); + it('re-throws the error without retrying the request', async () => { + beforeCreateService({ endpointUrl, rpcMethod }); + const service = createService({ endpointUrl, expectedError }); - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', - params: [], - }; - await ignoreRejection(service.request(jsonRpcRequest)); - await ignoreRejection(service.request(jsonRpcRequest)); - // The last retry breaks the circuit - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expectedError, - ); + const promise = service.request({ + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], }); - it('calls the onBreak callback once after the circuit breaks', async () => { - const clock = getClock(); - const mockFetch = jest.fn(() => { - throw producedError; - }); - const endpointUrl = 'https://rpc.example.chain'; - const onBreakListener = jest.fn(); - const service = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl, - }); - service.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); - service.onBreak(onBreakListener); - - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', - params: [], - }; - await ignoreRejection(service.request(jsonRpcRequest)); - await ignoreRejection(service.request(jsonRpcRequest)); - // The last retry breaks the circuit - await ignoreRejection(service.request(jsonRpcRequest)); - - expect(onBreakListener).toHaveBeenCalledTimes(1); - expect(onBreakListener).toHaveBeenCalledWith({ - error: expectedError, - endpointUrl: `${endpointUrl}/`, - }); - }); + await expect(promise).rejects.toThrow(expectedError); + }); - it('throws an error that includes the number of minutes until the circuit is re-closed if a request is attempted while the circuit is open', async () => { - const clock = getClock(); - const mockFetch = jest.fn(() => { - throw producedError; - }); - const endpointUrl = 'https://rpc.example.chain'; - const logger = { warn: jest.fn() }; - const service = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl, - logger, - }); - service.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); + it('does not call onRetry', async () => { + beforeCreateService({ endpointUrl, rpcMethod }); + const onRetryListener = jest.fn(); + const service = createService({ endpointUrl, expectedError }); + service.onRetry(onRetryListener); - const jsonRpcRequest = { + await ignoreRejection( + service.request({ id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', + jsonrpc: '2.0', + method: rpcMethod, params: [], - }; - await ignoreRejection(service.request(jsonRpcRequest)); - await ignoreRejection(service.request(jsonRpcRequest)); - await ignoreRejection(service.request(jsonRpcRequest)); - - clock.tick(60000); - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expect.objectContaining({ - code: errorCodes.rpc.resourceUnavailable, - message: - 'RPC endpoint returned too many errors, retrying in 29 minutes. Consider using a different RPC endpoint.', - }), - ); - }); + }), + ); + expect(onRetryListener).not.toHaveBeenCalled(); + }); - it('logs the original CircuitBreakError if a request is attempted while the circuit is open', async () => { - const clock = getClock(); - const mockFetch = jest.fn(() => { - throw producedError; - }); - const endpointUrl = 'https://rpc.example.chain'; - const logger = { warn: jest.fn() }; - const service = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl, - logger, - }); - service.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); + it('does not call onBreak', async () => { + beforeCreateService({ endpointUrl, rpcMethod }); + const onBreakListener = jest.fn(); + const service = createService({ endpointUrl, expectedError }); + service.onBreak(onBreakListener); - const jsonRpcRequest = { + await ignoreRejection( + service.request({ id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', + jsonrpc: '2.0', + method: rpcMethod, params: [], - }; - await ignoreRejection(service.request(jsonRpcRequest)); - await ignoreRejection(service.request(jsonRpcRequest)); - await ignoreRejection(service.request(jsonRpcRequest)); - await ignoreRejection(service.request(jsonRpcRequest)); - - expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Execution prevented because the circuit breaker is open', - }), - ); - }); + }), + ); + expect(onBreakListener).not.toHaveBeenCalled(); }); - describe('if a failover service is provided', () => { - it('still retries a constantly failing request up to 4 more times before re-throwing the error, if `request` is only called once', async () => { - const clock = getClock(); - const mockFetch = jest.fn(() => { - throw producedError; - }); - const failoverService = buildMockRpcService(); - const service = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl: 'https://rpc.example.chain', - failoverService, - }); - service.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); + it('does not call onDegraded', async () => { + beforeCreateService({ endpointUrl, rpcMethod }); + const onDegradedListener = jest.fn(); + const service = createService({ endpointUrl, expectedError }); + service.onDegraded(onDegradedListener); - const jsonRpcRequest = { + await ignoreRejection( + service.request({ id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', + jsonrpc: '2.0', + method: rpcMethod, params: [], - }; - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expectedError, - ); - expect(mockFetch).toHaveBeenCalledTimes(5); - }); + }), + ); + expect(onDegradedListener).not.toHaveBeenCalled(); + }); - it('forwards the request to the failover service in addition to the primary endpoint while the circuit is broken, stopping when the primary endpoint recovers', async () => { - const clock = getClock(); - const jsonRpcRequest = { + it('does not call onAvailable', async () => { + beforeCreateService({ endpointUrl, rpcMethod }); + const onAvailableListener = jest.fn(); + const service = createService({ endpointUrl, expectedError }); + service.onAvailable(onAvailableListener); + + await ignoreRejection( + service.request({ id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', + jsonrpc: '2.0', + method: rpcMethod, params: [], - }; - let invocationCounter = 0; - const mockFetch = jest.fn(async () => { - invocationCounter += 1; - if (invocationCounter === 17) { - return new Response( - JSON.stringify({ - id: jsonRpcRequest.id, - jsonrpc: jsonRpcRequest.jsonrpc, - result: 'ok', - }), - ); - } - throw producedError; - }); - const failoverService = buildMockRpcService(); - const service = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl: 'https://rpc.example.chain', - fetchOptions: { - headers: { - 'X-Foo': 'bar', - }, - }, - failoverService, - }); - service.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); + }), + ); + expect(onAvailableListener).not.toHaveBeenCalled(); + }); - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expectedError, - ); - expect(mockFetch).toHaveBeenCalledTimes(5); + /* eslint-enable jest/require-top-level-describe */ +} - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expectedError, - ); - expect(mockFetch).toHaveBeenCalledTimes(10); +/** + * These are tests that exercise logic for cases in which the request cannot be + * made because the `fetch` calls throws a specific error. + * + * @param args - The arguments + * @param args.getClock - A function that returns the Sinon clock, set in + * `beforeEach`. + * @param args.producedError - The error produced when `fetch` is called. + * @param args.expectedError - The error that a call to the service's `request` + * method is expected to produce. + */ +function testsForRetriableFetchErrors({ + getClock, + producedError, + expectedError, +}: { + getClock: () => SinonFakeTimers; + producedError: Error; + expectedError: string | jest.Constructable | RegExp | Error; +}) { + // This function is designed to be used inside of a describe, so this won't be + // a problem in practice. + /* eslint-disable jest/require-top-level-describe */ - // The last retry breaks the circuit - await service.request(jsonRpcRequest); - expect(mockFetch).toHaveBeenCalledTimes(15); - expect(failoverService.request).toHaveBeenCalledTimes(1); - expect(failoverService.request).toHaveBeenNthCalledWith( - 1, - jsonRpcRequest, - { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-Foo': 'bar', - }, - method: 'POST', - body: JSON.stringify(jsonRpcRequest), - }, - ); + it('retries a constantly failing request up to 4 more times before re-throwing the error, if `request` is only called once', async () => { + const clock = getClock(); + const mockFetch = jest.fn(() => { + throw producedError; + }); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + service.onRetry(() => { + clock.next(); + }); - await service.request(jsonRpcRequest); - // The circuit is broken, so the `fetch` is not attempted - expect(mockFetch).toHaveBeenCalledTimes(15); - expect(failoverService.request).toHaveBeenCalledTimes(2); - expect(failoverService.request).toHaveBeenNthCalledWith( - 2, - jsonRpcRequest, - { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-Foo': 'bar', - }, - method: 'POST', - body: JSON.stringify(jsonRpcRequest), - }, - ); + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + expect(mockFetch).toHaveBeenCalledTimes(5); + }); - clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); - await service.request(jsonRpcRequest); - expect(mockFetch).toHaveBeenCalledTimes(16); - // The circuit breaks again - expect(failoverService.request).toHaveBeenCalledTimes(3); - expect(failoverService.request).toHaveBeenNthCalledWith( - 2, - jsonRpcRequest, - { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-Foo': 'bar', - }, - method: 'POST', - body: JSON.stringify(jsonRpcRequest), - }, - ); + it('calls the onDegraded callback once for each retry round', async () => { + const clock = getClock(); + const mockFetch = jest.fn(() => { + throw producedError; + }); + const endpointUrl = 'https://rpc.example.chain'; + const onDegradedListener = jest.fn(); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl, + }); + service.onRetry(() => { + clock.next(); + }); - clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); - // Finally the request succeeds - const response = await service.request(jsonRpcRequest); - expect(response).toStrictEqual({ - id: 1, - jsonrpc: '2.0', - result: 'ok', - }); - expect(mockFetch).toHaveBeenCalledTimes(17); - expect(failoverService.request).toHaveBeenCalledTimes(3); + service.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(onDegradedListener).toHaveBeenCalledTimes(2); + expect(onDegradedListener).toHaveBeenCalledWith({ + endpointUrl: `${endpointUrl}/`, + error: expectedError, }); + }); - it('still calls onBreak each time the circuit breaks from the perspective of the primary endpoint', async () => { - const clock = getClock(); - const mockFetch = jest.fn(() => { - throw producedError; - }); - const endpointUrl = 'https://rpc.example.chain'; - const failoverEndpointUrl = 'https://failover.endpoint'; - const failoverService = buildMockRpcService({ - endpointUrl: new URL(failoverEndpointUrl), - }); - const onBreakListener = jest.fn(); - const service = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl, - failoverService, - }); - service.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); - service.onBreak(onBreakListener); + it('still re-throws the error even after the circuit breaks', async () => { + const clock = getClock(); + const mockFetch = jest.fn(() => { + throw producedError; + }); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + service.onRetry(() => { + clock.next(); + }); - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', - params: [], - }; - await ignoreRejection(() => service.request(jsonRpcRequest)); - await ignoreRejection(() => service.request(jsonRpcRequest)); - // The last retry breaks the circuit - await service.request(jsonRpcRequest); - clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); - // The circuit breaks again - await service.request(jsonRpcRequest); + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + }); - expect(onBreakListener).toHaveBeenCalledTimes(2); - expect(onBreakListener).toHaveBeenCalledWith({ - error: expectedError, - endpointUrl: `${endpointUrl}/`, - failoverEndpointUrl: `${failoverEndpointUrl}/`, - }); + it('calls the onBreak callback once after the circuit breaks', async () => { + const clock = getClock(); + const mockFetch = jest.fn(() => { + throw producedError; }); + const endpointUrl = 'https://rpc.example.chain'; + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl, + }); + service.onRetry(() => { + clock.next(); + }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ + error: expectedError, + endpointUrl: `${endpointUrl}/`, + }); + }); - it('throws an error that includes the number of minutes until the circuit is re-closed if a request is attempted while the circuit is open', async () => { - const clock = getClock(); - const mockFetch = jest.fn(() => { - throw producedError; - }); - const endpointUrl = 'https://rpc.example.chain'; - const failoverEndpointUrl = 'https://failover.endpoint'; - const logger = { warn: jest.fn() }; - const failoverService = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl: failoverEndpointUrl, - logger, - }); - failoverService.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); - const onBreakListener = jest.fn(); - const service = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl, - failoverService, - logger, - }); - service.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); - service.onBreak(onBreakListener); - - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', - params: [], - }; - // Get through the first two rounds of retries on the primary - await ignoreRejection(() => service.request(jsonRpcRequest)); - await ignoreRejection(() => service.request(jsonRpcRequest)); - // The last retry breaks the circuit and sends the request to the failover - await ignoreRejection(() => service.request(jsonRpcRequest)); - // Get through the first two rounds of retries on the failover - await ignoreRejection(() => service.request(jsonRpcRequest)); - await ignoreRejection(() => service.request(jsonRpcRequest)); - - // The last retry breaks the circuit on the failover - clock.tick(60000); - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expect.objectContaining({ - code: errorCodes.rpc.resourceUnavailable, - message: - 'RPC endpoint returned too many errors, retrying in 29 minutes. Consider using a different RPC endpoint.', - }), - ); - expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Execution prevented because the circuit breaker is open', - }), - ); + it('throws an error that includes the number of minutes until the circuit is re-closed if a request is attempted while the circuit is open', async () => { + const clock = getClock(); + const mockFetch = jest.fn(() => { + throw producedError; + }); + const endpointUrl = 'https://rpc.example.chain'; + const logger = { warn: jest.fn() }; + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl, + logger, + }); + service.onRetry(() => { + clock.next(); }); - it('logs the original CircuitBreakError if a request is attempted while the circuit is open', async () => { - const clock = getClock(); - const mockFetch = jest.fn(() => { - throw producedError; - }); - const endpointUrl = 'https://rpc.example.chain'; - const failoverEndpointUrl = 'https://failover.endpoint'; - const logger = { warn: jest.fn() }; - const failoverService = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl: failoverEndpointUrl, - logger, - }); - failoverService.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); - const onBreakListener = jest.fn(); - const service = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl, - failoverService, - logger, - }); - service.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); - service.onBreak(onBreakListener); + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Get through the first two rounds of retries + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + // Advance a minute to test that the message updates dynamically as time passes + clock.tick(60000); + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + message: + 'RPC endpoint returned too many errors, retrying in 29 minutes. Consider using a different RPC endpoint.', + }), + ); + }); - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', - params: [], - }; - // Get through the first two rounds of retries on the primary - await ignoreRejection(() => service.request(jsonRpcRequest)); - await ignoreRejection(() => service.request(jsonRpcRequest)); - // The last retry breaks the circuit and sends the request to the failover - await ignoreRejection(() => service.request(jsonRpcRequest)); - // Get through the first two rounds of retries on the failover - await ignoreRejection(() => service.request(jsonRpcRequest)); - await ignoreRejection(() => service.request(jsonRpcRequest)); - - // The last retry breaks the circuit on the failover - await ignoreRejection(() => service.request(jsonRpcRequest)); - expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Execution prevented because the circuit breaker is open', - }), - ); + it('logs the original CircuitBreakError if a request is attempted while the circuit is open', async () => { + const clock = getClock(); + const mockFetch = jest.fn(() => { + throw producedError; + }); + const endpointUrl = 'https://rpc.example.chain'; + const logger = { warn: jest.fn() }; + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl, + logger, + }); + service.onRetry(() => { + clock.next(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Execution prevented because the circuit breaker is open', + }), + ); + }); + + it('calls the onAvailable callback if the endpoint becomes degraded via errors and then recovers', async () => { + const clock = getClock(); + let invocationIndex = -1; + const mockFetch = jest.fn(async () => { + invocationIndex += 1; + if (invocationIndex === DEFAULT_MAX_RETRIES + 1) { + return new Response( + JSON.stringify({ + id: 1, + jsonrpc: '2.0', + result: { some: 'data' }, + }), + ); + } + throw producedError; + }); + const endpointUrl = 'https://rpc.example.chain'; + const onAvailableListener = jest.fn(); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl, + }); + service.onAvailable(onAvailableListener); + service.onRetry(() => { + clock.next(); }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Cause the retry policy to give up + await ignoreRejection(service.request(jsonRpcRequest)); + await service.request(jsonRpcRequest); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); }); + + /* eslint-enable jest/require-top-level-describe */ } /** @@ -1613,363 +1454,203 @@ function testsForRetriableResponses({ }) { // This function is designed to be used inside of a describe, so this won't be // a problem in practice. - /* eslint-disable jest/no-identical-title */ - - describe('if there is no failover service provided', () => { - it('retries a constantly failing request up to 4 more times before re-throwing the error, if `request` is only called once', async () => { - const clock = getClock(); - const scope = nock('https://rpc.example.chain') - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }) - .times(5) - .reply(httpStatus, responseBody); - const service = new RpcService({ - fetch, - btoa, - endpointUrl: 'https://rpc.example.chain', - }); - service.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); + /* eslint-disable jest/require-top-level-describe,jest/no-identical-title */ - const jsonRpcRequest = { + it('retries a constantly failing request up to 4 more times before re-throwing the error, if `request` is only called once', async () => { + const clock = getClock(); + const scope = nock('https://rpc.example.chain') + .post('/', { id: 1, - jsonrpc: '2.0' as const, + jsonrpc: '2.0', method: 'eth_chainId', params: [], - }; - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expectedError, - ); - expect(scope.isDone()).toBe(true); + }) + .times(5) + .reply(httpStatus, responseBody); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + service.onRetry(() => { + clock.next(); }); - it('still re-throws the error even after the circuit breaks', async () => { - const clock = getClock(); - nock('https://rpc.example.chain') - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }) - .times(15) - .reply(httpStatus, responseBody); - const service = new RpcService({ - fetch, - btoa, - endpointUrl: 'https://rpc.example.chain', - }); - service.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + expect(scope.isDone()).toBe(true); + }); - const jsonRpcRequest = { + it('still re-throws the error even after the circuit breaks', async () => { + const clock = getClock(); + nock('https://rpc.example.chain') + .post('/', { id: 1, - jsonrpc: '2.0' as const, + jsonrpc: '2.0', method: 'eth_chainId', params: [], - }; - await ignoreRejection(service.request(jsonRpcRequest)); - await ignoreRejection(service.request(jsonRpcRequest)); - // The last retry breaks the circuit - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expectedError, - ); + }) + .times(15) + .reply(httpStatus, responseBody); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + service.onRetry(() => { + clock.next(); }); - it('calls the onBreak callback once after the circuit breaks', async () => { - const clock = getClock(); - const endpointUrl = 'https://rpc.example.chain'; - nock(endpointUrl) - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }) - .times(15) - .reply(httpStatus, responseBody); - const onBreakListener = jest.fn(); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - }); - service.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); - service.onBreak(onBreakListener); + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + }); - const jsonRpcRequest = { + it('calls the onBreak callback once after the circuit breaks', async () => { + const clock = getClock(); + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { id: 1, - jsonrpc: '2.0' as const, + jsonrpc: '2.0', method: 'eth_chainId', params: [], - }; - await ignoreRejection(service.request(jsonRpcRequest)); - await ignoreRejection(service.request(jsonRpcRequest)); - // The last retry breaks the circuit - await ignoreRejection(service.request(jsonRpcRequest)); - - expect(onBreakListener).toHaveBeenCalledTimes(1); - expect(onBreakListener).toHaveBeenCalledWith({ - error: expectedOnBreakError, - endpointUrl: `${endpointUrl}/`, - }); + }) + .times(15) + .reply(httpStatus, responseBody); + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + service.onRetry(() => { + clock.next(); + }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ + error: expectedOnBreakError, + endpointUrl: `${endpointUrl}/`, }); }); - describe('if a failover service is provided', () => { - it('still retries a constantly failing request up to 4 more times before re-throwing the error, if `request` is only called once', async () => { - const clock = getClock(); - const scope = nock('https://rpc.example.chain') - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }) - .times(5) - .reply(httpStatus, responseBody); - const failoverService = buildMockRpcService(); - const service = new RpcService({ - fetch, - btoa, - endpointUrl: 'https://rpc.example.chain', - failoverService, - }); - service.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); - - const jsonRpcRequest = { + it('throws an error that includes the number of minutes until the circuit is re-closed if a request is attempted while the circuit is open', async () => { + const clock = getClock(); + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { id: 1, - jsonrpc: '2.0' as const, + jsonrpc: '2.0', method: 'eth_chainId', params: [], - }; - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expectedError, - ); - expect(scope.isDone()).toBe(true); + }) + .times(15) + .reply(httpStatus, responseBody); + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, }); - - it('forwards the request to the failover service in addition to the primary endpoint while the circuit is broken, stopping when the primary endpoint recovers', async () => { - const clock = getClock(); - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', - params: [], - }; - let invocationCounter = 0; - nock('https://rpc.example.chain') - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }) - .times(17) - .reply(() => { - invocationCounter += 1; - if (invocationCounter === 17) { - return [ - 200, - JSON.stringify({ - id: jsonRpcRequest.id, - jsonrpc: jsonRpcRequest.jsonrpc, - result: 'ok', - }), - ]; - } - return [httpStatus, responseBody]; - }); - const failoverService = buildMockRpcService(); - const service = new RpcService({ - fetch, - btoa, - endpointUrl: 'https://rpc.example.chain', - fetchOptions: { - headers: { - 'X-Foo': 'bar', - }, - }, - failoverService, - }); - service.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); - - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expectedError, - ); - expect(invocationCounter).toBe(5); - - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expectedError, - ); - expect(invocationCounter).toBe(10); - - // The last retry breaks the circuit - await service.request(jsonRpcRequest); - expect(invocationCounter).toBe(15); - expect(failoverService.request).toHaveBeenCalledTimes(1); - expect(failoverService.request).toHaveBeenNthCalledWith( - 1, - jsonRpcRequest, - { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-Foo': 'bar', - }, - method: 'POST', - body: JSON.stringify(jsonRpcRequest), - }, - ); - - await service.request(jsonRpcRequest); - // The circuit is broken, so the `fetch` is not attempted - expect(invocationCounter).toBe(15); - expect(failoverService.request).toHaveBeenCalledTimes(2); - expect(failoverService.request).toHaveBeenNthCalledWith( - 2, - jsonRpcRequest, - { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-Foo': 'bar', - }, - method: 'POST', - body: JSON.stringify(jsonRpcRequest), - }, - ); - - clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); - await service.request(jsonRpcRequest); - expect(invocationCounter).toBe(16); - // The circuit breaks again - expect(failoverService.request).toHaveBeenCalledTimes(3); - expect(failoverService.request).toHaveBeenNthCalledWith( - 2, - jsonRpcRequest, - { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-Foo': 'bar', - }, - method: 'POST', - body: JSON.stringify(jsonRpcRequest), - }, - ); - - clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); - // Finally the request succeeds - const response = await service.request(jsonRpcRequest); - expect(response).toStrictEqual({ - id: 1, - jsonrpc: '2.0', - result: 'ok', - }); - expect(invocationCounter).toBe(17); - expect(failoverService.request).toHaveBeenCalledTimes(3); + service.onRetry(() => { + clock.next(); }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Get through the first two rounds of retries + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + // Advance a minute to test that the message updates dynamically as time passes + clock.tick(60000); + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + message: + 'RPC endpoint returned too many errors, retrying in 29 minutes. Consider using a different RPC endpoint.', + }), + ); + }); - it('still calls onBreak each time the circuit breaks from the perspective of the primary endpoint', async () => { - const clock = getClock(); - nock('https://rpc.example.chain') - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }) - .times(16) - .reply(httpStatus, responseBody); - const endpointUrl = 'https://rpc.example.chain'; - const failoverEndpointUrl = 'https://failover.endpoint'; - const failoverService = buildMockRpcService({ - endpointUrl: new URL(failoverEndpointUrl), - }); - const onBreakListener = jest.fn(); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - failoverService, - }); - service.onRetry(() => { - // We don't need to await this promise; adding it to the promise - // queue is enough to continue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.nextAsync(); - }); - service.onBreak(onBreakListener); - - const jsonRpcRequest = { + it('logs the original CircuitBreakError if a request is attempted while the circuit is open', async () => { + const clock = getClock(); + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { id: 1, - jsonrpc: '2.0' as const, + jsonrpc: '2.0', method: 'eth_chainId', params: [], - }; - await ignoreRejection(() => service.request(jsonRpcRequest)); - await ignoreRejection(() => service.request(jsonRpcRequest)); - // The last retry breaks the circuit - await service.request(jsonRpcRequest); - clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); - // The circuit breaks again - await service.request(jsonRpcRequest); - - expect(onBreakListener).toHaveBeenCalledTimes(2); - expect(onBreakListener).toHaveBeenCalledWith({ - error: expectedOnBreakError, - endpointUrl: `${endpointUrl}/`, - failoverEndpointUrl: `${failoverEndpointUrl}/`, - }); + }) + .times(15) + .reply(httpStatus, responseBody); + const logger = { warn: jest.fn() }; + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + logger, + }); + service.onRetry(() => { + clock.next(); }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Execution prevented because the circuit breaker is open', + }), + ); }); - /* eslint-enable jest/no-identical-title */ -} - -/** - * Constructs a fake RPC service for use as a failover in tests. - * - * @param overrides - The overrides. - * @returns The fake failover service. - */ -function buildMockRpcService( - overrides?: Partial, -): AbstractRpcService { - return { - endpointUrl: new URL('https://test.example'), - request: jest.fn(), - onRetry: jest.fn(), - onBreak: jest.fn(), - onDegraded: jest.fn(), - ...overrides, - }; + /* eslint-enable jest/require-top-level-describe,jest/no-identical-title */ } diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index 7021e8167cd..b1e85158950 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -4,26 +4,24 @@ import type { } from '@metamask/controller-utils'; import { BrokenCircuitError, - CircuitState, HttpError, createServicePolicy, handleWhen, } from '@metamask/controller-utils'; import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors'; -import type { JsonRpcRequest } from '@metamask/utils'; -import { - Duration, - getErrorMessage, - hasProperty, - type Json, - type JsonRpcParams, - type JsonRpcResponse, +import { Duration, getErrorMessage, hasProperty } from '@metamask/utils'; +import type { + Json, + JsonRpcParams, + JsonRpcRequest, + JsonRpcResponse, } from '@metamask/utils'; import deepmerge from 'deepmerge'; import type { Logger } from 'loglevel'; import type { AbstractRpcService } from './abstract-rpc-service'; -import type { AddToCockatielEventData, FetchOptions } from './shared'; +import type { FetchOptions } from './shared'; +import { projectLogger, createModuleLogger } from '../logger'; /** * Options for the RpcService constructor. @@ -38,11 +36,6 @@ export type RpcServiceOptions = { * The URL of the RPC endpoint to hit. */ endpointUrl: URL | string; - /** - * An RPC service that represents a failover endpoint which will be invoked - * while the circuit for _this_ service is open. - */ - failoverService?: AbstractRpcService; /** * A function that can be used to make an HTTP request. If your JavaScript * environment supports `fetch` natively, you'll probably want to pass that; @@ -65,6 +58,8 @@ export type RpcServiceOptions = { policyOptions?: Omit; }; +const log = createModuleLogger(projectLogger, 'RpcService'); + /** * The maximum number of times that a failing service should be re-run before * giving up. @@ -238,25 +233,25 @@ function stripCredentialsFromUrl(url: URL): URL { */ export class RpcService implements AbstractRpcService { /** - * The function used to make an HTTP request. + * The URL of the RPC endpoint. */ - readonly #fetch: typeof fetch; + readonly endpointUrl: URL; /** - * The URL of the RPC endpoint. + * The last error that the retry policy captured (or `undefined` if the last + * execution of the service was successful). */ - readonly endpointUrl: URL; + lastError: Error | undefined; /** - * A common set of options that the request options will extend. + * The function used to make an HTTP request. */ - readonly #fetchOptions: FetchOptions; + readonly #fetch: typeof fetch; /** - * An RPC service that represents a failover endpoint which will be invoked - * while the circuit for _this_ service is open. + * A common set of options that the request options will extend. */ - readonly #failoverService: RpcServiceOptions['failoverService']; + readonly #fetchOptions: FetchOptions; /** * A `loglevel` logger. @@ -277,7 +272,6 @@ export class RpcService implements AbstractRpcService { const { btoa: givenBtoa, endpointUrl, - failoverService, fetch: givenFetch, logger, fetchOptions = {}, @@ -292,10 +286,9 @@ export class RpcService implements AbstractRpcService { givenBtoa, ); this.endpointUrl = stripCredentialsFromUrl(normalizedUrl); - this.#failoverService = failoverService; this.#logger = logger; - const policy = createServicePolicy({ + this.#policy = createServicePolicy({ maxRetries: DEFAULT_MAX_RETRIES, maxConsecutiveFailures: DEFAULT_MAX_CONSECUTIVE_FAILURES, ...policyOptions, @@ -315,7 +308,24 @@ export class RpcService implements AbstractRpcService { ); }), }); - this.#policy = policy; + } + + /** + * Resets the underlying composite Cockatiel policy. + * + * This is useful in a collection of RpcServices where some act as failovers + * for others where you effectively want to invalidate the failovers when the + * primary recovers. + */ + resetPolicy() { + this.#policy.reset(); + } + + /** + * @returns The state of the underlying circuit. + */ + getCircuitState() { + return this.#policy.getCircuitState(); } /** @@ -325,12 +335,7 @@ export class RpcService implements AbstractRpcService { * @returns What {@link ServicePolicy.onRetry} returns. * @see {@link createServicePolicy} */ - onRetry( - listener: AddToCockatielEventData< - Parameters[0], - { endpointUrl: string } - >, - ) { + onRetry(listener: Parameters[0]) { return this.#policy.onRetry((data) => { listener({ ...data, endpointUrl: this.endpointUrl.toString() }); }); @@ -338,26 +343,28 @@ export class RpcService implements AbstractRpcService { /** * Listens for when the RPC service retries the request too many times in a - * row. + * row, causing the underlying circuit to break. * * @param listener - The callback to be called when the circuit is broken. * @returns What {@link ServicePolicy.onBreak} returns. * @see {@link createServicePolicy} */ - onBreak( - listener: AddToCockatielEventData< - Parameters[0], - { endpointUrl: string; failoverEndpointUrl?: string } - >, - ) { + onBreak(listener: Parameters[0]) { return this.#policy.onBreak((data) => { - listener({ - ...data, - endpointUrl: this.endpointUrl.toString(), - failoverEndpointUrl: this.#failoverService - ? this.#failoverService.endpointUrl.toString() - : undefined, - }); + // `{ isolated: true }` is a special object that shows up when `isolate` + // is called on the circuit breaker. Usually `isolate` is used to hold the + // circuit open, but we (ab)use this method in `createServicePolicy` to + // reset the circuit breaker policy. When we do this, we don't want to + // call `onBreak` handlers, because then it causes + // `NetworkController:rpcEndpointUnavailable` and + // `NetworkController:rpcEndpointChainUnavailable` to be published. So we + // have to ignore that object here. The consequence is that `isolate` + // doesn't function the way it is intended, at least in the context of an + // RpcService. However, we are making a bet that we won't need to use it + // other than how we are already using it. + if (!('isolated' in data)) { + listener({ ...data, endpointUrl: this.endpointUrl.toString() }); + } }); } @@ -369,21 +376,27 @@ export class RpcService implements AbstractRpcService { * @returns What {@link ServicePolicy.onDegraded} returns. * @see {@link createServicePolicy} */ - onDegraded( - listener: AddToCockatielEventData< - Parameters[0], - { endpointUrl: string } - >, - ) { + onDegraded(listener: Parameters[0]) { return this.#policy.onDegraded((data) => { listener({ ...(data ?? {}), endpointUrl: this.endpointUrl.toString() }); }); } /** - * Makes a request to the RPC endpoint. If the circuit is open because this - * request has failed too many times, the request is forwarded to a failover - * service (if provided). + * Listens for when the policy underlying this RPC service is available. + * + * @param listener - The callback to be called when the request is available. + * @returns What {@link ServicePolicy.onAvailable} returns. + * @see {@link createServicePolicy} + */ + onAvailable(listener: Parameters[0]) { + return this.#policy.onAvailable(() => { + listener({ endpointUrl: this.endpointUrl.toString() }); + }); + } + + /** + * Makes a request to the RPC endpoint. * * This overload is specifically designed for `eth_getBlockByNumber`, which * can return a `result` of `null` despite an expected `Result` being @@ -405,9 +418,7 @@ export class RpcService implements AbstractRpcService { ): Promise | JsonRpcResponse>; /** - * Makes a request to the RPC endpoint. If the circuit is open because this - * request has failed too many times, the request is forwarded to a failover - * service (if provided). + * Makes a request to the RPC endpoint. * * This overload is designed for all RPC methods except for * `eth_getBlockByNumber`, which are expected to return a `result` of the @@ -437,21 +448,7 @@ export class RpcService implements AbstractRpcService { jsonRpcRequest, fetchOptions, ); - - try { - return await this.#processRequest(completeFetchOptions); - } catch (error) { - if ( - this.#policy.circuitBreakerPolicy.state === CircuitState.Open && - this.#failoverService !== undefined - ) { - return await this.#failoverService.request( - jsonRpcRequest, - completeFetchOptions, - ); - } - throw error; - } + return await this.#executeAndProcessRequest(completeFetchOptions); } /** @@ -528,19 +525,46 @@ export class RpcService implements AbstractRpcService { * @throws A generic HTTP client JSON-RPC error (code -32050) for any other 4xx HTTP status codes. * @throws A "parse" JSON-RPC error (code -32700) if the response is not valid JSON. */ - async #processRequest( + async #executeAndProcessRequest( fetchOptions: FetchOptions, ): Promise | JsonRpcResponse> { let response: Response | undefined; try { - return await this.#policy.execute(async () => { - response = await this.#fetch(this.endpointUrl, fetchOptions); - if (!response.ok) { - throw new HttpError(response.status); - } - return await response.json(); - }); + log( + `[${this.endpointUrl}] Circuit state`, + this.#policy.getCircuitState(), + ); + const jsonDecodedResponse = await this.#policy.execute( + async (context) => { + log( + 'REQUEST INITIATED:', + this.endpointUrl.toString(), + '::', + fetchOptions, + // @ts-expect-error This property _is_ here, the type of + // ServicePolicy is just wrong. + `(attempt ${context.attempt + 1})`, + ); + response = await this.#fetch(this.endpointUrl, fetchOptions); + if (!response.ok) { + throw new HttpError(response.status); + } + log( + 'REQUEST SUCCESSFUL:', + this.endpointUrl.toString(), + response.status, + ); + return await response.json(); + }, + ); + this.lastError = undefined; + return jsonDecodedResponse; } catch (error) { + log('REQUEST ERROR:', this.endpointUrl.toString(), error); + + this.lastError = + error instanceof Error ? error : new Error(getErrorMessage(error)); + if (error instanceof HttpError) { const status = error.httpStatus; if (status === 401) { diff --git a/packages/network-controller/src/rpc-service/shared.ts b/packages/network-controller/src/rpc-service/shared.ts index e33ae6129ad..c66cb1082c8 100644 --- a/packages/network-controller/src/rpc-service/shared.ts +++ b/packages/network-controller/src/rpc-service/shared.ts @@ -1,13 +1,58 @@ +import type { + CockatielEvent, + CockatielEventEmitter, +} from '@metamask/controller-utils'; + /** * Equivalent to the built-in `FetchOptions` type, but renamed for clarity. */ export type FetchOptions = RequestInit; /** - * Extends an event listener that Cockatiel uses so that when it is called, more - * data can be supplied in the event object. + * Converts a Cockatiel event type to an event emitter type. */ -export type AddToCockatielEventData = - EventListener extends (data: infer Data) => void - ? (data: Data extends void ? AdditionalData : Data & AdditionalData) => void +export type CockatielEventToEventEmitter = + Event extends CockatielEvent + ? CockatielEventEmitter : never; + +/** + * Obtains the event data type from a Cockatiel event or event listener type. + */ +export type ExtractCockatielEventData = + CockatielEventOrEventListener extends CockatielEvent + ? Data + : CockatielEventOrEventListener extends (data: infer Data) => void + ? Data + : never; + +/** + * Extends the data that a Cockatiel event listener is called with additional + * data. + */ +export type ExtendCockatielEventData = + OriginalData extends void ? AdditionalData : OriginalData & AdditionalData; + +/** + * Removes keys from the data that a Cockatiel event listner is called with. + */ +export type ExcludeCockatielEventData< + OriginalData, + Keys extends PropertyKey, +> = OriginalData extends void ? void : Omit; + +/** + * Converts a Cockatiel event type to an event listener type, but adding the + * requested data. + */ +export type CockatielEventToEventListenerWithData = ( + data: ExtendCockatielEventData, Data>, +) => void; + +/** + * Converts a Cockatiel event listener type to an event emitter type. + */ +export type CockatielEventToEventEmitterWithData = + CockatielEventEmitter< + ExtendCockatielEventData, Data> + >; diff --git a/packages/network-controller/tests/NetworkController.provider.test.ts b/packages/network-controller/tests/NetworkController.provider.test.ts new file mode 100644 index 00000000000..d2ad58a9c2e --- /dev/null +++ b/packages/network-controller/tests/NetworkController.provider.test.ts @@ -0,0 +1,894 @@ +import { + DEFAULT_CIRCUIT_BREAK_DURATION, + DEFAULT_DEGRADED_THRESHOLD, +} from '@metamask/controller-utils'; +import nock from 'nock'; +import type { SinonFakeTimers } from 'sinon'; +import { useFakeTimers } from 'sinon'; + +import { + buildCustomNetworkConfiguration, + buildCustomRpcEndpoint, + withController, +} from './helpers'; +import { NetworkStatus } from '../src/constants'; + +describe('NetworkController provider tests', () => { + let clock: SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + it('sets the status of a network client to "available" the first time its (sole) RPC endpoint returns a 2xx response', async () => { + const endpointUrl = 'https://some.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(endpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: endpointUrl, + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller }) => { + const { provider } = controller.getNetworkClientById(networkClientId); + + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Available, + ); + }, + ); + }); + + it('sets the status of a network client to "degraded" when its (sole) RPC endpoint responds with 2xx but slowly', async () => { + const endpointUrl = 'https://some.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(endpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(() => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return [ + 200, + { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }, + ]; + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(() => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return [ + 200, + { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }, + ]; + }); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: endpointUrl, + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller }) => { + const { provider } = controller.getNetworkClientById(networkClientId); + + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Degraded, + ); + }, + ); + }); + + it('sets the status of a network client to "degraded" when failed requests to its (sole) RPC endpoint reach the max number of retries', async () => { + const endpointUrl = 'https://some.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(endpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(5) + .reply(503); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: endpointUrl, + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + clock.next(); + }); + const { provider } = controller.getNetworkClientById(networkClientId); + + await expect( + provider.request({ + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }), + ).rejects.toThrow('RPC endpoint not found or unavailable'); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Degraded, + ); + }, + ); + }); + + it('transitions the status of a network client from "degraded" to "available" the first time a failover is activated and returns a 2xx response', async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(primaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: primaryEndpointUrl, + failoverUrls: [secondaryEndpointUrl], + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + clock.next(); + }); + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, break the circuit, fail over to the secondary. + await provider.request(request); + + expect(stateChangeListener).toHaveBeenCalledTimes(2); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'degraded', + }, + ], + ); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'available', + }, + ], + ); + }, + ); + }); + + it('does not transition the status of a network client from "degraded" the first time a failover is activated if it returns a non-2xx response', async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(primaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(5) + .reply(503); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: primaryEndpointUrl, + failoverUrls: [secondaryEndpointUrl], + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + clock.next(); + }); + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, break the circuit, fail over to the secondary, + // run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Degraded, + ); + }, + ); + }); + + it('does not transition the status of a network client from "degraded" the first time a failover is activated if requests are slow to complete', async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(primaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(() => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return [ + 200, + { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }, + ]; + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(() => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return [ + 200, + { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }, + ]; + }); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: primaryEndpointUrl, + failoverUrls: [secondaryEndpointUrl], + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + clock.next(); + }); + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, break the circuit, fail over to the secondary. + await provider.request(request); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Degraded, + ); + }, + ); + }); + + it('sets the status of a network client to "unavailable" when all of its RPC endpoints consistently return 5xx errors, reaching the max consecutive number of failures', async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(primaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: primaryEndpointUrl, + failoverUrls: [secondaryEndpointUrl], + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + clock.next(); + }); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, break the circuit, fail over to the secondary, + // run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the secondary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the secondary, break the circuit. + await expect(provider.request(request)).rejects.toThrow(expectedError); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Unavailable, + ); + }, + ); + }); + + it('transitions the status of a network client from "unavailable" to "available" when its (sole) RPC endpoint consistently returns 5xx errors for a while and then recovers', async () => { + const endpointUrl = 'https://some.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(endpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: endpointUrl, + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + clock.next(); + }); + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the endpoint, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the endpoint, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the endpoint, break the circuit. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Wait until the circuit break duration passes and hit the endpoint + // again. + clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); + await provider.request(request); + + expect(stateChangeListener).toHaveBeenCalledTimes(3); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'degraded', + }, + ], + ); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'unavailable', + }, + ], + ); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 3, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'available', + }, + ], + ); + }, + ); + }); + + it('transitions the status of a network client from "available" to "unavailable" when its (sole) RPC endpoint responds with 2xx and then returns too many 5xx responses, reaching the max number of consecutive failures', async () => { + const endpointUrl = 'https://some.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(endpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: endpointUrl, + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + clock.next(); + }); + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the endpoint and see that it is successful. + await provider.request(request); + // Wait for the block tracker to reset the cache. (For some reason, + // multiple timers exist.) + clock.runAll(); + // Hit the endpoint, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the endpoint, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the endpoint, break the circuit. + await expect(provider.request(request)).rejects.toThrow(expectedError); + + expect(stateChangeListener).toHaveBeenCalledTimes(3); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'available', + }, + ], + ); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'degraded', + }, + ], + ); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 3, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'unavailable', + }, + ], + ); + }, + ); + }); +}); diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index 2e85aaddaad..70f56cff41e 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -33,6 +33,7 @@ import { buildUpdateNetworkCustomRpcEndpointFields, INFURA_NETWORKS, TESTNET, + withController, } from './helpers'; import type { RootMessenger } from './helpers'; import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; @@ -50,8 +51,6 @@ import type { NetworkClientId, NetworkConfiguration, NetworkControllerEvents, - NetworkControllerMessenger, - NetworkControllerOptions, NetworkControllerStateChangeEvent, NetworkState, } from '../src/NetworkController'; @@ -508,6 +507,21 @@ describe('NetworkController', () => { }, ], }, + "0x279f": Object { + "blockExplorerUrls": Array [], + "chainId": "0x279f", + "defaultRpcEndpointIndex": 0, + "name": "Monad Testnet", + "nativeCurrency": "MON", + "rpcEndpoints": Array [ + Object { + "failoverUrls": Array [], + "networkClientId": "monad-testnet", + "type": "infura", + "url": "https://monad-testnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, "0x38": Object { "blockExplorerUrls": Array [], "chainId": "0x38", @@ -695,6 +709,21 @@ describe('NetworkController', () => { }, ], }, + "0x279f": Object { + "blockExplorerUrls": Array [], + "chainId": "0x279f", + "defaultRpcEndpointIndex": 0, + "name": "Monad Testnet", + "nativeCurrency": "MON", + "rpcEndpoints": Array [ + Object { + "failoverUrls": Array [], + "networkClientId": "monad-testnet", + "type": "infura", + "url": "https://monad-testnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, "0x38": Object { "blockExplorerUrls": Array [], "chainId": "0x38", @@ -2001,6 +2030,21 @@ describe('NetworkController', () => { enableRpcFailover: expect.any(Function), disableRpcFailover: expect.any(Function), }, + 'monad-testnet': { + blockTracker: expect.anything(), + configuration: { + type: NetworkClientType.Infura, + failoverRpcUrls: [], + infuraProjectId, + chainId: '0x279f', + ticker: 'MON', + network: InfuraNetworkType['monad-testnet'], + }, + provider: expect.anything(), + destroy: expect.any(Function), + enableRpcFailover: expect.any(Function), + disableRpcFailover: expect.any(Function), + }, 'optimism-mainnet': { blockTracker: expect.anything(), configuration: { @@ -4637,6 +4681,7 @@ describe('NetworkController', () => { expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( 2, { + networkClientId: infuraNetworkType, networkClientConfiguration: { infuraProjectId, failoverRpcUrls: ['https://first.failover.endpoint'], @@ -4654,6 +4699,7 @@ describe('NetworkController', () => { expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( 3, { + networkClientId: 'BBBB-BBBB-BBBB-BBBB', networkClientConfiguration: { chainId: infuraChainId, failoverRpcUrls: ['https://second.failover.endpoint'], @@ -4670,6 +4716,7 @@ describe('NetworkController', () => { expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( 4, { + networkClientId: 'CCCC-CCCC-CCCC-CCCC', networkClientConfiguration: { chainId: infuraChainId, failoverRpcUrls: ['https://third.failover.endpoint'], @@ -6047,6 +6094,7 @@ describe('NetworkController', () => { expect( createAutoManagedNetworkClientSpy, ).toHaveBeenNthCalledWith(3, { + networkClientId: infuraNetworkType, networkClientConfiguration: { chainId: infuraChainId, failoverRpcUrls: ['https://failover.endpoint'], @@ -6278,6 +6326,7 @@ describe('NetworkController', () => { expect( createAutoManagedNetworkClientSpy, ).toHaveBeenNthCalledWith(3, { + networkClientId: 'AAAA-AAAA-AAAA-AAAA', networkClientConfiguration: { chainId: infuraChainId, failoverRpcUrls: ['https://first.failover.endpoint'], @@ -6293,6 +6342,7 @@ describe('NetworkController', () => { expect( createAutoManagedNetworkClientSpy, ).toHaveBeenNthCalledWith(4, { + networkClientId: 'BBBB-BBBB-BBBB-BBBB', networkClientConfiguration: { chainId: infuraChainId, failoverRpcUrls: ['https://second.failover.endpoint'], @@ -7265,6 +7315,7 @@ describe('NetworkController', () => { expect( createAutoManagedNetworkClientSpy, ).toHaveBeenNthCalledWith(3, { + networkClientId: 'BBBB-BBBB-BBBB-BBBB', networkClientConfiguration: { chainId: infuraChainId, failoverRpcUrls: ['https://failover.endpoint'], @@ -8135,6 +8186,7 @@ describe('NetworkController', () => { expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( 3, { + networkClientId: 'BBBB-BBBB-BBBB-BBBB', networkClientConfiguration: { chainId: '0x1337', failoverRpcUrls: ['https://first.failover.endpoint'], @@ -8151,6 +8203,7 @@ describe('NetworkController', () => { expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( 4, { + networkClientId: 'CCCC-CCCC-CCCC-CCCC', networkClientConfiguration: { chainId: '0x1337', failoverRpcUrls: ['https://second.failover.endpoint'], @@ -9136,6 +9189,7 @@ describe('NetworkController', () => { }); expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', networkClientConfiguration: { chainId: '0x1337', failoverRpcUrls: ['https://failover.endpoint'], @@ -10292,6 +10346,7 @@ describe('NetworkController', () => { expect( createAutoManagedNetworkClientSpy, ).toHaveBeenNthCalledWith(4, { + networkClientId: 'CCCC-CCCC-CCCC-CCCC', networkClientConfiguration: { chainId: infuraChainId, failoverRpcUrls: ['https://first.failover.endpoint'], @@ -10307,6 +10362,7 @@ describe('NetworkController', () => { expect( createAutoManagedNetworkClientSpy, ).toHaveBeenNthCalledWith(5, { + networkClientId: 'DDDD-DDDD-DDDD-DDDD', networkClientConfiguration: { chainId: infuraChainId, failoverRpcUrls: ['https://second.failover.endpoint'], @@ -11008,6 +11064,7 @@ describe('NetworkController', () => { ); expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + networkClientId: 'CCCC-CCCC-CCCC-CCCC', networkClientConfiguration: { chainId: '0x1337', failoverRpcUrls: ['https://first.failover.endpoint'], @@ -11021,6 +11078,7 @@ describe('NetworkController', () => { isRpcFailoverEnabled: true, }); expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + networkClientId: 'DDDD-DDDD-DDDD-DDDD', networkClientConfiguration: { chainId: '0x1337', failoverRpcUrls: ['https://second.failover.endpoint'], @@ -11737,6 +11795,7 @@ describe('NetworkController', () => { expect( createAutoManagedNetworkClientSpy, ).toHaveBeenNthCalledWith(6, { + networkClientId: 'CCCC-CCCC-CCCC-CCCC', networkClientConfiguration: { chainId: anotherInfuraChainId, failoverRpcUrls: ['https://first.failover.endpoint'], @@ -11752,6 +11811,7 @@ describe('NetworkController', () => { expect( createAutoManagedNetworkClientSpy, ).toHaveBeenNthCalledWith(7, { + networkClientId: 'DDDD-DDDD-DDDD-DDDD', networkClientConfiguration: { chainId: anotherInfuraChainId, failoverRpcUrls: ['https://second.failover.endpoint'], @@ -12434,6 +12494,7 @@ describe('NetworkController', () => { expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( 4, { + networkClientId: 'CCCC-CCCC-CCCC-CCCC', networkClientConfiguration: { chainId: '0x2448', failoverRpcUrls: ['https://first.failover.endpoint'], @@ -12450,6 +12511,7 @@ describe('NetworkController', () => { expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( 5, { + networkClientId: 'DDDD-DDDD-DDDD-DDDD', networkClientConfiguration: { chainId: '0x2448', failoverRpcUrls: ['https://second.failover.endpoint'], @@ -14793,6 +14855,21 @@ describe('NetworkController', () => { }, ], }, + "0x279f": Object { + "blockExplorerUrls": Array [], + "chainId": "0x279f", + "defaultRpcEndpointIndex": 0, + "name": "Monad Testnet", + "nativeCurrency": "MON", + "rpcEndpoints": Array [ + Object { + "failoverUrls": Array [], + "networkClientId": "monad-testnet", + "type": "infura", + "url": "https://monad-testnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, "0x38": Object { "blockExplorerUrls": Array [], "chainId": "0x38", @@ -14962,6 +15039,21 @@ describe('NetworkController', () => { }, ], }, + "0x279f": Object { + "blockExplorerUrls": Array [], + "chainId": "0x279f", + "defaultRpcEndpointIndex": 0, + "name": "Monad Testnet", + "nativeCurrency": "MON", + "rpcEndpoints": Array [ + Object { + "failoverUrls": Array [], + "networkClientId": "monad-testnet", + "type": "infura", + "url": "https://monad-testnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, "0x38": Object { "blockExplorerUrls": Array [], "chainId": "0x38", @@ -15131,6 +15223,21 @@ describe('NetworkController', () => { }, ], }, + "0x279f": Object { + "blockExplorerUrls": Array [], + "chainId": "0x279f", + "defaultRpcEndpointIndex": 0, + "name": "Monad Testnet", + "nativeCurrency": "MON", + "rpcEndpoints": Array [ + Object { + "failoverUrls": Array [], + "networkClientId": "monad-testnet", + "type": "infura", + "url": "https://monad-testnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, "0x38": Object { "blockExplorerUrls": Array [], "chainId": "0x38", @@ -16571,53 +16678,6 @@ function lookupNetworkTests({ }); } -type WithControllerCallback = ({ - controller, -}: { - controller: NetworkController; - messenger: RootMessenger; - networkControllerMessenger: NetworkControllerMessenger; -}) => Promise | ReturnValue; - -type WithControllerOptions = Partial; - -type WithControllerArgs = - | [WithControllerCallback] - | [WithControllerOptions, WithControllerCallback]; - -/** - * Builds a controller based on the given options, and calls the given function - * with that controller. - * - * @param args - Either a function, or an options bag + a function. The options - * bag is equivalent to the options that NetworkController takes (although - * `messenger` and `infuraProjectId` are filled in if not given); the function - * will be called with the built controller. - * @returns Whatever the callback returns. - */ -async function withController( - ...args: WithControllerArgs -): Promise { - const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; - const messenger = buildRootMessenger(); - const networkControllerMessenger = buildNetworkControllerMessenger(messenger); - const controller = new NetworkController({ - messenger: networkControllerMessenger, - infuraProjectId: 'infura-project-id', - getRpcServiceOptions: () => ({ - fetch, - btoa, - }), - ...rest, - }); - try { - return await fn({ controller, messenger, networkControllerMessenger }); - } finally { - const { blockTracker } = controller.getProviderAndBlockTracker(); - await blockTracker?.destroy(); - } -} - /** * Builds an object that `createNetworkClient` returns. * diff --git a/packages/network-controller/tests/helpers.ts b/packages/network-controller/tests/helpers.ts index 5cb3735fbb0..6dfe4d92b5d 100644 --- a/packages/network-controller/tests/helpers.ts +++ b/packages/network-controller/tests/helpers.ts @@ -7,12 +7,11 @@ import { } from '@metamask/controller-utils'; import type { ErrorReportingServiceCaptureExceptionAction } from '@metamask/error-reporting-service'; import type { InternalProvider } from '@metamask/eth-json-rpc-provider'; -import { - Messenger, - type MockAnyNamespace, - type MessengerActions, - type MessengerEvents, - MOCK_ANY_NAMESPACE, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, } from '@metamask/messenger'; import type { Hex } from '@metamask/utils'; import { v4 as uuidV4 } from 'uuid'; @@ -21,14 +20,14 @@ import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; import { FakeProvider } from '../../../tests/fake-provider'; import type { FakeProviderStub } from '../../../tests/fake-provider'; import { buildTestObject } from '../../../tests/helpers'; -import { - type BuiltInNetworkClientId, - type CustomNetworkClientId, - type NetworkClient, - type NetworkClientConfiguration, - type NetworkClientId, - type NetworkConfiguration, - type NetworkController, +import { NetworkController } from '../src'; +import type { + BuiltInNetworkClientId, + CustomNetworkClientId, + NetworkClient, + NetworkClientConfiguration, + NetworkClientId, + NetworkConfiguration, } from '../src'; import type { AutoManagedNetworkClient } from '../src/create-auto-managed-network-client'; import type { @@ -37,6 +36,7 @@ import type { CustomRpcEndpoint, InfuraRpcEndpoint, NetworkControllerMessenger, + NetworkControllerOptions, UpdateNetworkCustomRpcEndpointFields, } from '../src/NetworkController'; import { RpcEndpointType } from '../src/NetworkController'; @@ -217,7 +217,7 @@ export function buildMockGetNetworkClientById( function getNetworkClientById( networkClientId: CustomNetworkClientId, ): AutoManagedNetworkClient; - // eslint-disable-next-line jsdoc/require-jsdoc + function getNetworkClientById(networkClientId: string): NetworkClient { const mockNetworkClientConfiguration = mergedMockNetworkClientConfigurationsByNetworkClientId[networkClientId]; @@ -270,7 +270,7 @@ export function buildMockFindNetworkClientIdByChainId( }; function findNetworkClientIdByChainId(chainId: Hex): NetworkClientId; - // eslint-disable-next-line jsdoc/require-jsdoc + function findNetworkClientIdByChainId(chainId: Hex): NetworkClientId { const networkClientConfigForChainId = mergedMockNetworkClientConfigurationsByNetworkClientId[chainId]; @@ -598,3 +598,50 @@ function generateCustomRpcEndpointUrl(): string { testEndpointCounter += 1; return url; } + +type WithControllerCallback = ({ + controller, +}: { + controller: NetworkController; + messenger: RootMessenger; + networkControllerMessenger: NetworkControllerMessenger; +}) => Promise | ReturnValue; + +type WithControllerOptions = Partial; + +type WithControllerArgs = + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback]; + +/** + * Builds a controller based on the given options, and calls the given function + * with that controller. + * + * @param args - Either a function, or an options bag + a function. The options + * bag is equivalent to the options that NetworkController takes (although + * `messenger` and `infuraProjectId` are filled in if not given); the function + * will be called with the built controller. + * @returns Whatever the callback returns. + */ +export async function withController( + ...args: WithControllerArgs +): Promise { + const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; + const messenger = buildRootMessenger(); + const networkControllerMessenger = buildNetworkControllerMessenger(messenger); + const controller = new NetworkController({ + messenger: networkControllerMessenger, + infuraProjectId: 'infura-project-id', + getRpcServiceOptions: () => ({ + fetch, + btoa, + }), + ...rest, + }); + try { + return await fn({ controller, messenger, networkControllerMessenger }); + } finally { + const { blockTracker } = controller.getProviderAndBlockTracker(); + await blockTracker?.destroy(); + } +} diff --git a/packages/network-controller/tests/network-client/helpers.ts b/packages/network-controller/tests/network-client/helpers.ts index 4f0dcb128ea..2740677a52e 100644 --- a/packages/network-controller/tests/network-client/helpers.ts +++ b/packages/network-controller/tests/network-client/helpers.ts @@ -3,13 +3,16 @@ import type { InfuraNetworkType } from '@metamask/controller-utils'; import { BUILT_IN_NETWORKS } from '@metamask/controller-utils'; import type { BlockTracker } from '@metamask/eth-block-tracker'; import EthQuery from '@metamask/eth-query'; -import type { Hex } from '@metamask/utils'; +import type { Hex, JsonRpcRequest } from '@metamask/utils'; import nock, { isDone as nockIsDone } from 'nock'; import type { Scope as NockScope } from 'nock'; import { useFakeTimers } from 'sinon'; import { createNetworkClient } from '../../src/create-network-client'; -import type { NetworkControllerOptions } from '../../src/NetworkController'; +import type { + NetworkClientId, + NetworkControllerOptions, +} from '../../src/NetworkController'; import type { NetworkClientConfiguration, Provider } from '../../src/types'; import { NetworkClientType } from '../../src/types'; import type { RootMessenger } from '../helpers'; @@ -85,7 +88,10 @@ type Response = { result?: any; httpStatus?: number; }; -export type MockResponse = { body: JSONRPCResponse | string } | Response; +export type MockResponse = + | { body: JSONRPCResponse | string } + | Response + | (() => Response | Promise); type CurriedMockRpcCallOptions = { request: MockRequest; // The response data. @@ -147,22 +153,12 @@ function mockRpcCall({ // eth-query always passes `params`, so even if we don't supply this property, // for consistency with makeRpcCall, assume that the `body` contains it const { method, params = [], ...rest } = request; - let httpStatus = 200; - let completeResponse: JSONRPCResponse | string = { id: 2, jsonrpc: '2.0' }; - if (response !== undefined) { - if ('body' in response) { - completeResponse = response.body; - } else { - if (response.error) { - completeResponse.error = response.error; - } else { - completeResponse.result = response.result; - } - if (response.httpStatus) { - httpStatus = response.httpStatus; - } - } - } + const httpStatus = + (typeof response === 'object' && + 'httpStatus' in response && + response.httpStatus) || + 200; + /* @ts-expect-error The types for Nock do not include `basePath` in the interface for Nock.Scope. */ const url = nockScope.basePath.includes('infura.io') ? `/v3/${MOCK_INFURA_PROJECT_ID}` @@ -196,26 +192,42 @@ function mockRpcCall({ if (error !== undefined) { return nockRequest.replyWithError(error); - } else if (completeResponse !== undefined) { - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return nockRequest.reply(httpStatus, (_, requestBody: any) => { - if (typeof completeResponse === 'string') { - return completeResponse; - } - - if (response !== undefined && !('body' in response)) { - if (response.id === undefined) { - completeResponse.id = requestBody.id; - } else { - completeResponse.id = response.id; - } - } - debug('Nock returning Response', completeResponse); - return completeResponse; - }); } - return nockRequest; + + return nockRequest.reply(async (_uri, requestBody) => { + const jsonRpcRequest = requestBody as JsonRpcRequest; + let resolvedResponse: Response | string | JSONRPCResponse | undefined; + if (typeof response === 'function') { + resolvedResponse = await response(); + } else if (response !== undefined && 'body' in response) { + resolvedResponse = response.body; + } else { + resolvedResponse = response; + } + + if ( + typeof resolvedResponse === 'string' || + resolvedResponse === undefined + ) { + return [httpStatus, resolvedResponse]; + } + + const { + id: jsonRpcId = jsonRpcRequest.id, + jsonrpc: jsonRpcVersion = jsonRpcRequest.jsonrpc, + result: jsonRpcResult, + error: jsonRpcError, + } = resolvedResponse; + + const completeResponse = { + id: jsonRpcId, + jsonrpc: jsonRpcVersion, + result: jsonRpcResult, + error: jsonRpcError, + }; + debug('Nock returning Response', completeResponse); + return [httpStatus, completeResponse]; + }); } type MockBlockTrackerRequestOptions = { @@ -316,6 +328,7 @@ export type MockOptions = { getBlockTrackerOptions?: NetworkControllerOptions['getBlockTrackerOptions']; expectedHeaders?: Record; messenger?: RootMessenger; + networkClientId?: NetworkClientId; isRpcFailoverEnabled?: boolean; }; @@ -474,6 +487,7 @@ export async function waitForPromiseToBeFulfilledAfterRunningAllTimers( * @param options.getRpcServiceOptions - RPC service options factory. * @param options.getBlockTrackerOptions - Block tracker options factory. * @param options.messenger - The root messenger to use in tests. + * @param options.networkClientId - The ID of the new network client. * @param options.isRpcFailoverEnabled - Whether or not the RPC failover * functionality is enabled. * @param fn - A function which will be called with an object that allows @@ -491,6 +505,7 @@ export async function withNetworkClient( getRpcServiceOptions = () => ({ fetch, btoa }), getBlockTrackerOptions = () => ({}), messenger = buildRootMessenger(), + networkClientId = 'some-network-client-id', isRpcFailoverEnabled = false, }: MockOptions, // TODO: Replace `any` with type @@ -540,6 +555,7 @@ export async function withNetworkClient( : `https://${infuraNetwork}.infura.io/v3/${MOCK_INFURA_PROJECT_ID}`; const networkClient = createNetworkClient({ + id: networkClientId, configuration: networkClientConfiguration, getRpcServiceOptions, getBlockTrackerOptions, diff --git a/packages/network-controller/tests/network-client/rpc-failover.ts b/packages/network-controller/tests/network-client/rpc-failover.ts index f214c939cb3..da09d22b9ae 100644 --- a/packages/network-controller/tests/network-client/rpc-failover.ts +++ b/packages/network-controller/tests/network-client/rpc-failover.ts @@ -26,7 +26,6 @@ export function testsForRpcFailoverBehavior({ failure, isRetriableFailure, getExpectedError, - getExpectedBreakError = getExpectedError, }: { providerType: ProviderType; requestToCall: MockRequest; @@ -96,7 +95,7 @@ export function testsForRpcFailoverBehavior({ }, async ({ makeRpcCall, clock }) => { messenger.subscribe( - 'NetworkController:rpcEndpointRequestRetried', + 'NetworkController:rpcEndpointRetried', () => { // Ensure that we advance to the next RPC request // retry, not the next block tracker request. @@ -120,188 +119,6 @@ export function testsForRpcFailoverBehavior({ }); }); - it('publishes the NetworkController:rpcEndpointUnavailable event when the failover occurs', async () => { - const failoverEndpointUrl = 'https://failover.endpoint/'; - - await withMockedCommunications({ providerType }, async (primaryComms) => { - await withMockedCommunications( - { - providerType: 'custom', - customRpcUrl: failoverEndpointUrl, - }, - async (failoverComms) => { - const request = requestToCall; - const requestToMock = getRequestToMock(request, blockNumber); - const additionalMockRpcCallOptions = - failure instanceof Error || typeof failure === 'string' - ? { error: failure } - : { response: failure }; - - // The first time a block-cacheable request is made, the - // latest block number is retrieved through the block - // tracker first. - primaryComms.mockNextBlockTrackerRequest({ - blockNumber, - }); - primaryComms.mockRpcCall({ - request: requestToMock, - times: maxConsecutiveFailures, - ...additionalMockRpcCallOptions, - }); - failoverComms.mockRpcCall({ - request: requestToMock, - response: { - result: 'ok', - }, - }); - - const messenger = buildRootMessenger(); - const rpcEndpointUnavailableEventHandler = jest.fn(); - messenger.subscribe( - 'NetworkController:rpcEndpointUnavailable', - rpcEndpointUnavailableEventHandler, - ); - - await withNetworkClient( - { - providerType, - isRpcFailoverEnabled: true, - failoverRpcUrls: [failoverEndpointUrl], - messenger, - getRpcServiceOptions: () => ({ - fetch, - btoa, - policyOptions: { - backoff: new ConstantBackoff(backoffDuration), - }, - }), - }, - async ({ makeRpcCall, clock, chainId, rpcUrl }) => { - messenger.subscribe( - 'NetworkController:rpcEndpointRequestRetried', - () => { - // Ensure that we advance to the next RPC request - // retry, not the next block tracker request. - // We also don't need to await this, it just needs to - // be added to the promise queue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.tickAsync(backoffDuration); - }, - ); - - for (let i = 0; i < numRequestsToMake - 1; i++) { - await ignoreRejection(makeRpcCall(request)); - } - await makeRpcCall(request); - - expect(rpcEndpointUnavailableEventHandler).toHaveBeenCalledWith( - { - chainId, - endpointUrl: rpcUrl, - failoverEndpointUrl, - error: getExpectedBreakError(rpcUrl), - }, - ); - }, - ); - }, - ); - }); - }); - - it('publishes the NetworkController:rpcEndpointUnavailable event when the failover becomes unavailable', async () => { - const failoverEndpointUrl = 'https://failover.endpoint/'; - - await withMockedCommunications({ providerType }, async (primaryComms) => { - await withMockedCommunications( - { - providerType: 'custom', - customRpcUrl: failoverEndpointUrl, - }, - async (failoverComms) => { - const request = requestToCall; - const requestToMock = getRequestToMock(request, blockNumber); - const additionalMockRpcCallOptions = - failure instanceof Error || typeof failure === 'string' - ? { error: failure } - : { response: failure }; - - // The first time a block-cacheable request is made, the - // latest block number is retrieved through the block - // tracker first. - primaryComms.mockNextBlockTrackerRequest({ - blockNumber, - }); - primaryComms.mockRpcCall({ - request: requestToMock, - times: maxConsecutiveFailures, - ...additionalMockRpcCallOptions, - }); - failoverComms.mockRpcCall({ - request: requestToMock, - times: maxConsecutiveFailures, - ...additionalMockRpcCallOptions, - }); - // Block tracker requests on the primary will fail over - failoverComms.mockNextBlockTrackerRequest({ - blockNumber, - }); - - const messenger = buildRootMessenger(); - const rpcEndpointUnavailableEventHandler = jest.fn(); - messenger.subscribe( - 'NetworkController:rpcEndpointUnavailable', - rpcEndpointUnavailableEventHandler, - ); - - await withNetworkClient( - { - providerType, - isRpcFailoverEnabled: true, - failoverRpcUrls: [failoverEndpointUrl], - messenger, - getRpcServiceOptions: () => ({ - fetch, - btoa, - policyOptions: { - backoff: new ConstantBackoff(backoffDuration), - }, - }), - }, - async ({ makeRpcCall, clock, chainId }) => { - messenger.subscribe( - 'NetworkController:rpcEndpointRequestRetried', - () => { - // Ensure that we advance to the next RPC request - // retry, not the next block tracker request. - // We also don't need to await this, it just needs to - // be added to the promise queue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.tickAsync(backoffDuration); - }, - ); - - for (let i = 0; i < maxConsecutiveFailures - 1; i++) { - await ignoreRejection(makeRpcCall(request)); - } - for (let i = 0; i < maxConsecutiveFailures; i++) { - await ignoreRejection(makeRpcCall(request)); - } - - expect( - rpcEndpointUnavailableEventHandler, - ).toHaveBeenNthCalledWith(2, { - chainId, - endpointUrl: failoverEndpointUrl, - error: getExpectedBreakError(failoverEndpointUrl), - }); - }, - ); - }, - ); - }); - }); - it('allows RPC service options to be customized', async () => { const customMaxConsecutiveFailures = 6; const customMaxRetries = 2; @@ -390,7 +207,7 @@ export function testsForRpcFailoverBehavior({ }, async ({ makeRpcCall, clock }) => { messenger.subscribe( - 'NetworkController:rpcEndpointRequestRetried', + 'NetworkController:rpcEndpointRetried', () => { // Ensure that we advance to the next RPC request // retry, not the next block tracker request. @@ -452,17 +269,14 @@ export function testsForRpcFailoverBehavior({ }), }, async ({ makeRpcCall, clock, rpcUrl }) => { - messenger.subscribe( - 'NetworkController:rpcEndpointRequestRetried', - () => { - // Ensure that we advance to the next RPC request - // retry, not the next block tracker request. - // We also don't need to await this, it just needs to - // be added to the promise queue. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.tickAsync(backoffDuration); - }, - ); + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + // We also don't need to await this, it just needs to + // be added to the promise queue. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(backoffDuration); + }); for (let i = 0; i < numRequestsToMake - 1; i++) { await ignoreRejection(makeRpcCall(request)); diff --git a/packages/network-enablement-controller/CHANGELOG.md b/packages/network-enablement-controller/CHANGELOG.md index 4f822cd7362..6ae5f5bb9f2 100644 --- a/packages/network-enablement-controller/CHANGELOG.md +++ b/packages/network-enablement-controller/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7220](https://github.com/MetaMask/core/pull/7220), [#7236](https://github.com/MetaMask/core/pull/7236), [#7257](https://github.com/MetaMask/core/pull/7257), [#7258](https://github.com/MetaMask/core/pull/7258), [#7289](https://github.com/MetaMask/core/pull/7289)) + - The dependencies moved are: + - `@metamask/multichain-network-controller` (^3.0.0) + - `@metamask/network-controller` (^27.0.0) + - `@metamask/transaction-controller` (^62.4.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. +- Bump `@metamask/transaction-controller` from `^62.4.0` to `^62.5.0` ([#7325](https://github.com/MetaMask/core/pull/7325)) + +### Fixed + +- Add missing MegaETH to POPULARE_NETWORKS list ([#7286](https://github.com/MetaMask/core/pull/7286)) + ## [4.0.0] ### Changed diff --git a/packages/network-enablement-controller/package.json b/packages/network-enablement-controller/package.json index ccebe17fc35..31beb6d0bb4 100644 --- a/packages/network-enablement-controller/package.json +++ b/packages/network-enablement-controller/package.json @@ -52,14 +52,14 @@ "@metamask/controller-utils": "^11.16.0", "@metamask/keyring-api": "^21.0.0", "@metamask/messenger": "^0.3.0", + "@metamask/multichain-network-controller": "^3.0.0", + "@metamask/network-controller": "^27.0.0", + "@metamask/transaction-controller": "^62.5.0", "@metamask/utils": "^11.8.1", "reselect": "^5.1.1" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/multichain-network-controller": "^3.0.0", - "@metamask/network-controller": "^26.0.0", - "@metamask/transaction-controller": "^62.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -70,11 +70,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/multichain-network-controller": "^3.0.0", - "@metamask/network-controller": "^26.0.0", - "@metamask/transaction-controller": "^62.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/network-enablement-controller/src/NetworkEnablementController.test.ts b/packages/network-enablement-controller/src/NetworkEnablementController.test.ts index 477eab8131a..53e1adad218 100644 --- a/packages/network-enablement-controller/src/NetworkEnablementController.test.ts +++ b/packages/network-enablement-controller/src/NetworkEnablementController.test.ts @@ -1,24 +1,17 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import { BuiltInNetworkName, ChainId } from '@metamask/controller-utils'; import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { RpcEndpointType } from '@metamask/network-controller'; -import { - TransactionStatus, - type TransactionMeta, -} from '@metamask/transaction-controller'; -import { - type CaipChainId, - type CaipNamespace, - type Hex, - KnownCaipNamespace, -} from '@metamask/utils'; +import { TransactionStatus } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { KnownCaipNamespace } from '@metamask/utils'; +import type { CaipChainId, CaipNamespace, Hex } from '@metamask/utils'; import { useFakeTimers } from 'sinon'; import { POPULAR_NETWORKS } from './constants'; diff --git a/packages/network-enablement-controller/src/constants.ts b/packages/network-enablement-controller/src/constants.ts index 27b8f226ca5..6bb3e652c00 100644 --- a/packages/network-enablement-controller/src/constants.ts +++ b/packages/network-enablement-controller/src/constants.ts @@ -12,4 +12,5 @@ export const POPULAR_NETWORKS = [ '0x2a15c308d', // Palm (11297108109) '0x3e7', // HyperEVM (999) '0x8f', // Monad (143) + '0x10e6', // MegaETH (4326) ]; diff --git a/packages/network-enablement-controller/src/selectors.ts b/packages/network-enablement-controller/src/selectors.ts index 63a18ce1c70..dae9938a419 100644 --- a/packages/network-enablement-controller/src/selectors.ts +++ b/packages/network-enablement-controller/src/selectors.ts @@ -65,15 +65,14 @@ export const createSelectorForEnabledNetworksForNamespace = ( export const selectAllEnabledNetworks = createSelector( selectEnabledNetworkMap, (enabledNetworkMap) => { - return (Object.keys(enabledNetworkMap) as CaipNamespace[]).reduce( - (acc, ns) => { - acc[ns] = Object.entries(enabledNetworkMap[ns]) - .filter(([, enabled]) => enabled) - .map(([id]) => id); - return acc; - }, - {} as Record, - ); + return Object.keys(enabledNetworkMap).reduce< + Record + >((acc, ns) => { + acc[ns] = Object.entries(enabledNetworkMap[ns]) + .filter(([, enabled]) => enabled) + .map(([id]) => id); + return acc; + }, {}); }, ); diff --git a/packages/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md index 4ac09635241..d36d801fa4c 100644 --- a/packages/notification-services-controller/CHANGELOG.md +++ b/packages/notification-services-controller/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/keyring-controller` (^25.0.0) + - `@metamask/profile-sync-controller` (^27.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. +- Modified background push utilities to handle more edgecases and not throw errors ([#7275](https://github.com/MetaMask/core/pull/7275)) + ## [21.0.0] ### Changed diff --git a/packages/notification-services-controller/package.json b/packages/notification-services-controller/package.json index 806db799620..88bb8514c25 100644 --- a/packages/notification-services-controller/package.json +++ b/packages/notification-services-controller/package.json @@ -113,7 +113,9 @@ "@contentful/rich-text-html-renderer": "^16.5.2", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.16.0", + "@metamask/keyring-controller": "^25.0.0", "@metamask/messenger": "^0.3.0", + "@metamask/profile-sync-controller": "^27.0.0", "@metamask/utils": "^11.8.1", "bignumber.js": "^9.1.2", "firebase": "^11.2.0", @@ -125,8 +127,6 @@ "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^25.0.0", - "@metamask/profile-sync-controller": "^27.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "@types/readable-stream": "^2.3.0", @@ -141,10 +141,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/keyring-controller": "^25.0.0", - "@metamask/profile-sync-controller": "^27.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts index 6190ebbbc3a..da073519f1b 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts @@ -1,16 +1,15 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import * as ControllerUtils from '@metamask/controller-utils'; -import { - KeyringTypes, - type KeyringControllerGetStateAction, - type KeyringControllerState, +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { + KeyringControllerGetStateAction, + KeyringControllerState, } from '@metamask/keyring-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { AuthenticationController } from '@metamask/profile-sync-controller'; import log from 'loglevel'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts index 7247aaa9851..d63cb04c6a9 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts @@ -8,13 +8,13 @@ import { isValidHexAddress, toChecksumHexAddress, } from '@metamask/controller-utils'; -import { - type KeyringControllerStateChangeEvent, - type KeyringControllerGetStateAction, - type KeyringControllerLockEvent, - type KeyringControllerUnlockEvent, - KeyringTypes, - type KeyringControllerState, +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { + KeyringControllerStateChangeEvent, + KeyringControllerGetStateAction, + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, + KeyringControllerState, } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-snap-notification.ts b/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-snap-notification.ts index 3dc58210010..d6d694179f2 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-snap-notification.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-snap-notification.ts @@ -1,5 +1,5 @@ import { TRIGGER_TYPES } from '../constants'; -import { type RawSnapNotification } from '../types/snaps'; +import type { RawSnapNotification } from '../types/snaps'; /** * Mocking Utility - create a mock raw snap notification diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.ts index b1621e739db..dd26ff2806a 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.ts @@ -48,9 +48,7 @@ export function processNotification( }; if (isFeatureAnnouncement(notification)) { - const n = processFeatureAnnouncement( - notification as FeatureAnnouncementRawNotification, - ); + const n = processFeatureAnnouncement(notification); n.isRead = isFeatureAnnouncementRead(n, readNotifications); return n; } @@ -63,7 +61,7 @@ export function processNotification( return processAPINotifications(notification); } - return exhaustedAllCases(notification as never); + return exhaustedAllCases(notification); } /** diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.ts index c27aecf0f54..17227edaa67 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.ts @@ -1,6 +1,6 @@ import { v4 as uuid } from 'uuid'; -import { type INotification } from '../types'; +import type { INotification } from '../types'; import type { RawSnapNotification } from '../types/snaps'; /** diff --git a/packages/notification-services-controller/src/NotificationServicesController/services/feature-announcements.test.ts b/packages/notification-services-controller/src/NotificationServicesController/services/feature-announcements.test.ts index 5352bbbe3c3..2168ae507ed 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/services/feature-announcements.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/services/feature-announcements.test.ts @@ -127,7 +127,7 @@ describe('Feature Announcement Notifications', () => { platformVersion: string | undefined, ) => { const apiResponse = createMockFeatureAnnouncementAPIResult(); - if (apiResponse.items && apiResponse.items[0]) { + if (apiResponse.items?.[0]) { apiResponse.items[0].fields.extensionMinimumVersionNumber = undefined; apiResponse.items[0].fields.mobileMinimumVersionNumber = undefined; apiResponse.items[0].fields.extensionMaximumVersionNumber = undefined; diff --git a/packages/notification-services-controller/src/NotificationServicesController/ui/constants.ts b/packages/notification-services-controller/src/NotificationServicesController/ui/constants.ts index 67d4a19d59e..f49448bd36b 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/ui/constants.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/ui/constants.ts @@ -1,7 +1,5 @@ -import { - NOTIFICATION_CHAINS_ID, - type NOTIFICATION_CHAINS_IDS, -} from '../constants/notification-schema'; +import { NOTIFICATION_CHAINS_ID } from '../constants/notification-schema'; +import type { NOTIFICATION_CHAINS_IDS } from '../constants/notification-schema'; export const NOTIFICATION_NETWORK_CURRENCY_NAME = { [NOTIFICATION_CHAINS_ID.ETHEREUM]: 'Ethereum', diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockMessenger.ts b/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockMessenger.ts index 6b3185207f2..283eff5eee6 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockMessenger.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockMessenger.ts @@ -1,9 +1,8 @@ -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { NotificationServicesPushControllerMessenger } from '..'; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/services/push/push-web.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/services/push/push-web.test.ts deleted file mode 100644 index bdba0303fc8..00000000000 --- a/packages/notification-services-controller/src/NotificationServicesPushController/services/push/push-web.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import * as FirebaseAppModule from 'firebase/app'; -import * as FirebaseMessagingModule from 'firebase/messaging'; -import * as FirebaseMessagingSWModule from 'firebase/messaging/sw'; -import log from 'loglevel'; - -import * as PushWebModule from './push-web'; -import { - createRegToken, - deleteRegToken, - listenToPushNotificationsReceived, - listenToPushNotificationsClicked, -} from './push-web'; -import { processNotification } from '../../../NotificationServicesController'; -import { createMockNotificationEthSent } from '../../../NotificationServicesController/mocks'; - -jest.mock('firebase/app'); -jest.mock('firebase/messaging'); -jest.mock('firebase/messaging/sw'); - -const mockEnv = { - apiKey: 'test-apiKey', - authDomain: 'test-authDomain', - storageBucket: 'test-storageBucket', - projectId: 'test-projectId', - messagingSenderId: 'test-messagingSenderId', - appId: 'test-appId', - measurementId: 'test-measurementId', - vapidKey: 'test-vapidKey', -}; - -const firebaseApp: FirebaseAppModule.FirebaseApp = { - name: '', - automaticDataCollectionEnabled: false, - options: mockEnv, -}; - -const arrangeFirebaseAppMocks = () => { - const mockGetApp = jest - .spyOn(FirebaseAppModule, 'getApp') - .mockReturnValue(firebaseApp); - - const mockInitializeApp = jest - .spyOn(FirebaseAppModule, 'initializeApp') - .mockReturnValue(firebaseApp); - - return { mockGetApp, mockInitializeApp }; -}; - -const arrangeFirebaseMessagingSWMocks = () => { - const mockIsSupported = jest - .spyOn(FirebaseMessagingSWModule, 'isSupported') - .mockResolvedValue(true); - - const getMessaging = jest - .spyOn(FirebaseMessagingSWModule, 'getMessaging') - .mockReturnValue({ app: firebaseApp }); - - const mockOnBackgroundMessageUnsub = jest.fn(); - const mockOnBackgroundMessage = jest - .spyOn(FirebaseMessagingSWModule, 'onBackgroundMessage') - .mockReturnValue(mockOnBackgroundMessageUnsub); - - return { - mockIsSupported, - getMessaging, - mockOnBackgroundMessage, - mockOnBackgroundMessageUnsub, - }; -}; - -const arrangeFirebaseMessagingMocks = () => { - const mockGetToken = jest - .spyOn(FirebaseMessagingModule, 'getToken') - .mockResolvedValue('test-token'); - - const mockDeleteToken = jest - .spyOn(FirebaseMessagingModule, 'deleteToken') - .mockResolvedValue(true); - - return { mockGetToken, mockDeleteToken }; -}; - -describe('createRegToken() tests', () => { - const TEST_TOKEN = 'test-token'; - - const arrange = () => { - const firebaseMocks = { - ...arrangeFirebaseAppMocks(), - ...arrangeFirebaseMessagingSWMocks(), - ...arrangeFirebaseMessagingMocks(), - }; - - firebaseMocks.mockGetToken.mockResolvedValue(TEST_TOKEN); - - return { - ...firebaseMocks, - }; - }; - - afterEach(() => { - jest.clearAllMocks(); - - // TODO - replace with jest.replaceProperty once we upgrade jest. - Object.defineProperty(PushWebModule, 'supportedCache', { value: null }); - }); - - it('should return a registration token when Firebase is supported', async () => { - const { mockGetApp, mockGetToken } = arrange(); - - const token = await createRegToken(mockEnv); - - expect(mockGetApp).toHaveBeenCalled(); - expect(mockGetToken).toHaveBeenCalled(); - expect(token).toBe(TEST_TOKEN); - }); - - it('should return null when Firebase is not supported', async () => { - const { mockIsSupported } = arrange(); - mockIsSupported.mockResolvedValueOnce(false); - - const token = await createRegToken(mockEnv); - - expect(token).toBeNull(); - }); - - it('should return null if an error occurs', async () => { - const { mockGetToken } = arrange(); - mockGetToken.mockRejectedValueOnce(new Error('Error getting token')); - - const token = await createRegToken(mockEnv); - - expect(token).toBeNull(); - }); - - it('should initialize firebase if has not been created yet', async () => { - const { mockGetApp, mockInitializeApp, mockGetToken } = arrange(); - mockGetApp.mockImplementation(() => { - throw new Error('mock Firebase GetApp failure'); - }); - - const token = await createRegToken(mockEnv); - - expect(mockGetApp).toHaveBeenCalled(); - expect(mockInitializeApp).toHaveBeenCalled(); - expect(mockGetToken).toHaveBeenCalled(); - expect(token).toBe(TEST_TOKEN); - }); -}); - -describe('deleteRegToken() tests', () => { - const arrange = () => { - return { - ...arrangeFirebaseAppMocks(), - ...arrangeFirebaseMessagingSWMocks(), - ...arrangeFirebaseMessagingMocks(), - }; - }; - - afterEach(() => { - jest.clearAllMocks(); - - // TODO - replace with jest.replaceProperty once we upgrade jest. - Object.defineProperty(PushWebModule, 'supportedCache', { value: null }); - }); - - it('should return true when the token is successfully deleted', async () => { - const { mockGetApp, mockDeleteToken } = arrange(); - - const result = await deleteRegToken(mockEnv); - - expect(mockGetApp).toHaveBeenCalled(); - expect(mockDeleteToken).toHaveBeenCalled(); - expect(result).toBe(true); - }); - - it('should return true when Firebase is not supported', async () => { - const { mockIsSupported, mockDeleteToken } = arrange(); - mockIsSupported.mockResolvedValueOnce(false); - - const result = await deleteRegToken(mockEnv); - - expect(result).toBe(true); - expect(mockDeleteToken).not.toHaveBeenCalled(); - }); - - it('should return false if an error occurs', async () => { - const { mockDeleteToken } = arrange(); - mockDeleteToken.mockRejectedValueOnce(new Error('Error deleting token')); - - const result = await deleteRegToken(mockEnv); - - expect(result).toBe(false); - }); -}); - -describe('listenToPushNotificationsReceived() tests', () => { - const arrange = () => { - return { - ...arrangeFirebaseAppMocks(), - ...arrangeFirebaseMessagingSWMocks(), - ...arrangeFirebaseMessagingMocks(), - }; - }; - - afterEach(() => { - jest.clearAllMocks(); - - // TODO - replace with jest.replaceProperty once we upgrade jest. - Object.defineProperty(PushWebModule, 'supportedCache', { value: null }); - }); - - it('should return an unsubscribe function when Firebase is supported', async () => { - const { mockGetApp, mockOnBackgroundMessage } = arrange(); - - const handler = jest.fn(); - const unsubscribe = await listenToPushNotificationsReceived( - mockEnv, - handler, - ); - - expect(mockGetApp).toHaveBeenCalled(); - expect(mockOnBackgroundMessage).toHaveBeenCalled(); - expect(unsubscribe).not.toBeNull(); - }); - - it('should return null when Firebase is not supported', async () => { - const { mockIsSupported } = arrange(); - mockIsSupported.mockResolvedValueOnce(false); - - const handler = jest.fn(); - const unsubscribe = await listenToPushNotificationsReceived( - mockEnv, - handler, - ); - - expect(unsubscribe).toBeNull(); - }); - - it('should be able to unsubscribe when invoked', async () => { - const { mockOnBackgroundMessageUnsub } = arrange(); - - const handler = jest.fn(); - const unsubscribe = await listenToPushNotificationsReceived( - mockEnv, - handler, - ); - - expect(unsubscribe).not.toBeNull(); - unsubscribe?.(); - expect(mockOnBackgroundMessageUnsub).toHaveBeenCalled(); - }); - - describe('handler tests', () => { - const arrangeTest = async () => { - const { mockOnBackgroundMessage } = arrange(); - - const handler = jest.fn(); - await listenToPushNotificationsReceived(mockEnv, handler); - - // Simulate receiving a background message - const invokeBackgroundMessage = mockOnBackgroundMessage.mock - .calls[0][1] as FirebaseMessagingModule.NextFn; - - return { - handler, - invokeBackgroundMessage, - }; - }; - - const arrangeActInvokeBackgroundMessage = async (testData: unknown) => { - const { handler, invokeBackgroundMessage } = await arrangeTest(); - - const payload = { - data: { - data: testData, - }, - } as unknown as FirebaseMessagingSWModule.MessagePayload; - - invokeBackgroundMessage(payload); - - return { handler }; - }; - - it('should call the handler with the processed notification', async () => { - const { handler } = await arrangeActInvokeBackgroundMessage( - JSON.stringify(createMockNotificationEthSent()), - ); - expect(handler).toHaveBeenCalled(); - }); - - it('should return early without calling handler if no data in background message', async () => { - const { handler } = await arrangeActInvokeBackgroundMessage( - JSON.stringify(undefined), - ); - expect(handler).not.toHaveBeenCalled(); - }); - - it('should error if unable to process and send a push notification', async () => { - const { handler, invokeBackgroundMessage } = await arrangeTest(); - jest.spyOn(log, 'error').mockImplementation(jest.fn()); - - const payload = { - data: { - data: JSON.stringify({ badNotification: 'bad' }), - }, - } as unknown as FirebaseMessagingSWModule.MessagePayload; - - await expect(invokeBackgroundMessage(payload)).rejects.toThrow( - expect.any(Error), - ); - - expect(handler).not.toHaveBeenCalled(); - }); - }); -}); - -describe('listenToPushNotificationsClicked() tests', () => { - const arrange = () => { - const mockHandler = jest.fn(); - return { mockHandler }; - }; - - const arrangeTest = () => { - const { mockHandler } = arrange(); - - const unsubscribe = listenToPushNotificationsClicked(mockHandler); - - const notificationData = processNotification( - createMockNotificationEthSent(), - ); - - const mockNotificationEvent = new Event( - 'notificationclick', - ) as NotificationEvent; - Object.assign(mockNotificationEvent, { - notification: { data: notificationData }, - }); - - return { - mockHandler, - unsubscribe, - notificationData, - mockNotificationEvent, - }; - }; - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should call the handler with the notification event and data when a notification is clicked', () => { - const { - mockHandler, - unsubscribe, - notificationData, - mockNotificationEvent, - } = arrangeTest(); - - self.dispatchEvent(mockNotificationEvent); - - expect(mockHandler).toHaveBeenCalledWith( - mockNotificationEvent, - notificationData, - ); - - unsubscribe(); - }); - - it('should remove the event listener when unsubscribe is called', () => { - const { mockHandler, unsubscribe, mockNotificationEvent } = arrangeTest(); - - unsubscribe(); - - self.dispatchEvent(mockNotificationEvent); - expect(mockHandler).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/services/push/push-web.ts b/packages/notification-services-controller/src/NotificationServicesPushController/services/push/push-web.ts deleted file mode 100644 index 125487b9a60..00000000000 --- a/packages/notification-services-controller/src/NotificationServicesPushController/services/push/push-web.ts +++ /dev/null @@ -1,176 +0,0 @@ -// We are defining that this file uses a webworker global scope. -// eslint-disable-next-line spaced-comment -/// -import type { FirebaseApp } from 'firebase/app'; -import { getApp, initializeApp } from 'firebase/app'; -import { getToken, deleteToken } from 'firebase/messaging'; -import { - getMessaging, - onBackgroundMessage, - isSupported, -} from 'firebase/messaging/sw'; -import type { Messaging, MessagePayload } from 'firebase/messaging/sw'; -import log from 'loglevel'; - -import type { Types } from '../../../NotificationServicesController'; -import { Processors } from '../../../NotificationServicesController'; -import { toRawAPINotification } from '../../../shared/to-raw-notification'; -import type { PushNotificationEnv } from '../../types/firebase'; - -declare const self: ServiceWorkerGlobalScope; - -// Exported to help testing -// eslint-disable-next-line import-x/no-mutable-exports -export let supportedCache: boolean | null = null; - -const getPushAvailability = async () => { - supportedCache ??= await isSupported(); - return supportedCache; -}; - -const createFirebaseApp = async ( - env: PushNotificationEnv, -): Promise => { - try { - return getApp(); - } catch { - const firebaseConfig = { - apiKey: env.apiKey, - authDomain: env.authDomain, - storageBucket: env.storageBucket, - projectId: env.projectId, - messagingSenderId: env.messagingSenderId, - appId: env.appId, - measurementId: env.measurementId, - }; - return initializeApp(firebaseConfig); - } -}; - -const getFirebaseMessaging = async ( - env: PushNotificationEnv, -): Promise => { - const supported = await getPushAvailability(); - if (!supported) { - return null; - } - - const app = await createFirebaseApp(env); - return getMessaging(app); -}; - -/** - * Creates a registration token for Firebase Cloud Messaging. - * - * @param env - env to configure push notifications - * @returns A promise that resolves with the registration token or null if an error occurs. - */ -export async function createRegToken( - env: PushNotificationEnv, -): Promise { - try { - const messaging = await getFirebaseMessaging(env); - if (!messaging) { - return null; - } - - const token = await getToken(messaging, { - serviceWorkerRegistration: self.registration, - vapidKey: env.vapidKey, - }); - return token; - } catch { - return null; - } -} - -/** - * Deletes the Firebase Cloud Messaging registration token. - * - * @param env - env to configure push notifications - * @returns A promise that resolves with true if the token was successfully deleted, false otherwise. - */ -export async function deleteRegToken( - env: PushNotificationEnv, -): Promise { - try { - const messaging = await getFirebaseMessaging(env); - if (!messaging) { - return true; - } - - await deleteToken(messaging); - return true; - } catch { - return false; - } -} - -/** - * Service Worker Listener for when push notifications are received. - * - * @param env - push notification environment - * @param handler - handler to actually showing notification, MUST BE PROVEDED - * @returns unsubscribe handler - */ -export async function listenToPushNotificationsReceived( - env: PushNotificationEnv, - handler: (notification: Types.INotification) => void | Promise, -): Promise<(() => void) | null> { - const messaging = await getFirebaseMessaging(env); - if (!messaging) { - return null; - } - - const unsubscribePushNotifications = onBackgroundMessage( - messaging, - // eslint-disable-next-line @typescript-eslint/no-misused-promises - async (payload: MessagePayload) => { - try { - const data: Types.UnprocessedRawNotification | undefined = payload?.data - ?.data - ? JSON.parse(payload?.data?.data) - : undefined; - - if (!data) { - return; - } - - const notificationData = toRawAPINotification(data); - const notification = Processors.processNotification(notificationData); - await handler(notification); - } catch (error) { - // Do Nothing, cannot parse a bad notification - log.error('Unable to send push notification:', { - notification: payload?.data?.data, - error, - }); - throw new Error('Unable to send push notification'); - } - }, - ); - - const unsubscribe = () => unsubscribePushNotifications(); - return unsubscribe; -} - -/** - * Service Worker Listener for when a notification is clicked - * - * @param handler - listen to NotificationEvent from the service worker - * @returns unsubscribe handler - */ -export function listenToPushNotificationsClicked( - handler: (e: NotificationEvent, notification?: Types.INotification) => void, -) { - const clickHandler = (event: NotificationEvent) => { - // Get Data - const data: Types.INotification = event?.notification?.data; - handler(event, data); - }; - - self.addEventListener('notificationclick', clickHandler); - const unsubscribe = () => - self.removeEventListener('notificationclick', clickHandler); - return unsubscribe; -} diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.test.ts index 0618b63ea50..cf64c39acfc 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.test.ts @@ -1,7 +1,6 @@ import * as FirebaseAppModule from 'firebase/app'; import * as FirebaseMessagingModule from 'firebase/messaging'; import * as FirebaseMessagingSWModule from 'firebase/messaging/sw'; -import log from 'loglevel'; import { createRegToken, @@ -320,28 +319,20 @@ describe('createSubscribeToPushNotifications() tests', () => { expect(mocks.mockOnClickHandler).not.toHaveBeenCalled(); }); - it('should fail to invoke handler if notification received has no data', async () => { - const mocks = await arrangeActNotificationReceived(undefined); - expect(mocks.mockOnReceivedHandler).not.toHaveBeenCalled(); - }); - - it('should throw error if unable to process a received push notification', async () => { - jest.spyOn(log, 'error').mockImplementation(jest.fn()); - const mocks = arrange(); - await actCreateSubscription(mocks); - - const firebaseCallback = mocks.mockOnBackgroundMessage.mock - .lastCall[1] as FirebaseMessagingModule.NextFn; - const payload = { - data: { - data: JSON.stringify({ badNotification: 'bad' }), - }, - } as unknown as FirebaseMessagingSWModule.MessagePayload; - - await expect(() => firebaseCallback(payload)).rejects.toThrow( - expect.any(Error), - ); - }); + const invalidNotificationDataPayloadsTests = [ + { data: undefined }, + { data: null }, + { data: 'not an object' }, + { data: { id: 'test-id', payload: { data: 'unexpected shape' } } }, + ]; + + it.each(invalidNotificationDataPayloadsTests)( + 'should fail to invoke handler if provided invalid push notification data payload - data $data', + async ({ data }) => { + const mocks = await arrangeActNotificationReceived(data); + expect(mocks.mockOnReceivedHandler).not.toHaveBeenCalled(); + }, + ); it('should invoke handler when notifications are clicked', async () => { const mocks = arrange(); diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts index f98b99a6dfe..f94ccbe5a6e 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts @@ -13,7 +13,10 @@ import type { Messaging, MessagePayload } from 'firebase/messaging/sw'; import log from 'loglevel'; import type { Types } from '../../NotificationServicesController'; -import { Processors } from '../../NotificationServicesController'; +import { + isOnChainRawNotification, + safeProcessNotification, +} from '../../NotificationServicesController'; import { toRawAPINotification } from '../../shared/to-raw-notification'; import type { NotificationServicesPushControllerMessenger } from '../NotificationServicesPushController'; import type { PushNotificationEnv } from '../types/firebase'; @@ -116,7 +119,7 @@ export async function deleteRegToken( */ async function listenToPushNotificationsReceived( env: PushNotificationEnv, - handler: (notification: Types.INotification) => void | Promise, + handler?: (notification: Types.INotification) => void | Promise, ): Promise<(() => void) | null> { const messaging = await getFirebaseMessaging(env); if (!messaging) { @@ -128,25 +131,33 @@ async function listenToPushNotificationsReceived( // eslint-disable-next-line @typescript-eslint/no-misused-promises async (payload: MessagePayload) => { try { - const data: Types.UnprocessedRawNotification | undefined = payload?.data - ?.data - ? JSON.parse(payload?.data?.data) - : undefined; + // MessagePayload shapes are not known + // TODO - provide open-api unfied backend/frontend types + // TODO - we will replace the underlying Data payload with the same Notification payload used by mobile + const data: unknown | null = JSON.parse(payload?.data?.data ?? 'null'); if (!data) { return; } + if (!isOnChainRawNotification(data)) { + return; + } + const notificationData = toRawAPINotification(data); - const notification = Processors.processNotification(notificationData); - await handler(notification); + const notification = safeProcessNotification(notificationData); + + if (!notification) { + return; + } + + await handler?.(notification); } catch (error) { // Do Nothing, cannot parse a bad notification log.error('Unable to send push notification:', { notification: payload?.data?.data, error, }); - throw new Error('Unable to send push notification'); } }, ); diff --git a/packages/permission-controller/CHANGELOG.md b/packages/permission-controller/CHANGELOG.md index 26e9feef43f..94d17729587 100644 --- a/packages/permission-controller/CHANGELOG.md +++ b/packages/permission-controller/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `PermissionController:getCaveat` action ([#7303](https://github.com/MetaMask/core/pull/7303)) + +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/approval-controller` (^8.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [12.1.1] ### Changed diff --git a/packages/permission-controller/package.json b/packages/permission-controller/package.json index a425555ab77..05f3fe91381 100644 --- a/packages/permission-controller/package.json +++ b/packages/permission-controller/package.json @@ -48,6 +48,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/approval-controller": "^8.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.16.0", "@metamask/json-rpc-engine": "^10.2.0", @@ -60,7 +61,6 @@ "nanoid": "^3.3.8" }, "devDependencies": { - "@metamask/approval-controller": "^8.0.0", "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", @@ -71,9 +71,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/approval-controller": "^8.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/permission-controller/src/PermissionController.test.ts b/packages/permission-controller/src/PermissionController.test.ts index 16c6768905e..a3a4bc1b7e9 100644 --- a/packages/permission-controller/src/PermissionController.test.ts +++ b/packages/permission-controller/src/PermissionController.test.ts @@ -1,12 +1,11 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import { isPlainObject } from '@metamask/controller-utils'; import { JsonRpcEngine } from '@metamask/json-rpc-engine'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import { @@ -6066,6 +6065,49 @@ describe('PermissionController', () => { }, }); }); + + it('action: PermissionController:getCaveat', async () => { + const messenger = getRootMessenger(); + const state = { + subjects: { + 'metamask.io': { + origin: 'metamask.io', + permissions: { + wallet_getSecretArray: { + id: 'escwEx9JrOxGZKZk3RkL4', + parentCapability: 'wallet_getSecretArray', + invoker: 'metamask.io', + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['bar'] }, + ], + date: 1632618373085, + }, + }, + }, + }, + }; + const options = getPermissionControllerOptions({ + messenger: getPermissionControllerMessenger(messenger), + state, + }); + + new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >(options); + + const result = messenger.call( + 'PermissionController:getCaveat', + 'metamask.io', + 'wallet_getSecretArray', + CaveatTypes.filterArrayResponse, + ); + + expect(result).toBe( + state.subjects['metamask.io'].permissions.wallet_getSecretArray + .caveats[0], + ); + }); }); describe('permission middleware', () => { diff --git a/packages/permission-controller/src/PermissionController.ts b/packages/permission-controller/src/PermissionController.ts index 27b95ef09ec..db4decb18d2 100644 --- a/packages/permission-controller/src/PermissionController.ts +++ b/packages/permission-controller/src/PermissionController.ts @@ -25,7 +25,8 @@ import { JsonRpcError } from '@metamask/rpc-errors'; import { hasProperty } from '@metamask/utils'; import type { Json, Mutable } from '@metamask/utils'; import deepFreeze from 'deep-freeze-strict'; -import { castDraft, produce as immerProduce, type Draft } from 'immer'; +import { castDraft, produce as immerProduce } from 'immer'; +import type { Draft } from 'immer'; import { nanoid } from 'nanoid'; import type { @@ -345,6 +346,14 @@ export type UpdateCaveat = { handler: GenericPermissionController['updateCaveat']; }; +/** + * Get a caveat value for a specified caveat type belonging to a specific target and origin. + */ +export type GetCaveat = { + type: `${typeof controllerName}:getCaveat`; + handler: GenericPermissionController['getCaveat']; +}; + /** * Clears all permissions from the {@link PermissionController}. */ @@ -379,7 +388,8 @@ export type PermissionControllerActions = | RevokeAllPermissions | RevokePermissionForAllSubjects | RevokePermissions - | UpdateCaveat; + | UpdateCaveat + | GetCaveat; /** * The generic state change event of the {@link PermissionController}. @@ -891,6 +901,16 @@ export class PermissionController< ); }, ); + + this.messenger.registerActionHandler( + `${controllerName}:getCaveat` as const, + (origin, target, caveatType) => + this.getCaveat( + origin, + target, + caveatType as ExtractAllowedCaveatTypes, + ), + ); } /** @@ -2342,7 +2362,9 @@ export class PermissionController< const { caveatPairs, leftUniqueCaveats, rightUniqueCaveats } = collectUniqueAndPairedCaveats(leftPermission, rightPermission); - const [mergedCaveats, caveatDiffMap] = caveatPairs.reduce( + const [mergedCaveats, caveatDiffMap] = caveatPairs.reduce< + [CaveatConstraint[], CaveatDiffMap] + >( ([caveats, diffMap], [leftCaveat, rightCaveat]) => { const [newCaveat, diff] = this.#mergeCaveat(leftCaveat, rightCaveat); @@ -2355,7 +2377,7 @@ export class PermissionController< return [caveats, diffMap]; }, - [[], {}] as [CaveatConstraint[], CaveatDiffMap], + [[], {}], ); const mergedRightUniqueCaveats = rightUniqueCaveats.map((caveat) => { diff --git a/packages/permission-controller/src/SubjectMetadataController.test.ts b/packages/permission-controller/src/SubjectMetadataController.test.ts index dc286f0df29..7cf2b53da9d 100644 --- a/packages/permission-controller/src/SubjectMetadataController.test.ts +++ b/packages/permission-controller/src/SubjectMetadataController.test.ts @@ -1,10 +1,9 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; diff --git a/packages/permission-controller/src/rpc-methods/revokePermissions.ts b/packages/permission-controller/src/rpc-methods/revokePermissions.ts index 9823b072c63..34b9b6352ef 100644 --- a/packages/permission-controller/src/rpc-methods/revokePermissions.ts +++ b/packages/permission-controller/src/rpc-methods/revokePermissions.ts @@ -1,10 +1,10 @@ import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; -import { - isNonEmptyArray, - type Json, - type JsonRpcRequest, - type NonEmptyArray, - type PendingJsonRpcResponse, +import { isNonEmptyArray } from '@metamask/utils'; +import type { + Json, + JsonRpcRequest, + NonEmptyArray, + PendingJsonRpcResponse, } from '@metamask/utils'; import { invalidParams } from '../errors'; diff --git a/packages/permission-log-controller/src/PermissionLogController.ts b/packages/permission-log-controller/src/PermissionLogController.ts index 846a7073fbe..255a49d817b 100644 --- a/packages/permission-log-controller/src/PermissionLogController.ts +++ b/packages/permission-log-controller/src/PermissionLogController.ts @@ -1,16 +1,16 @@ -import { - BaseController, - type ControllerGetStateAction, - type ControllerStateChangeEvent, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; import type { Messenger } from '@metamask/messenger'; -import { - type Json, - type JsonRpcRequest, - type JsonRpcParams, - type PendingJsonRpcResponse, - hasProperty, +import { hasProperty } from '@metamask/utils'; +import type { + Json, + JsonRpcRequest, + JsonRpcParams, + PendingJsonRpcResponse, } from '@metamask/utils'; import { diff --git a/packages/permission-log-controller/tests/PermissionLogController.test.ts b/packages/permission-log-controller/tests/PermissionLogController.test.ts index 57869e6af5c..ce271262618 100644 --- a/packages/permission-log-controller/tests/PermissionLogController.test.ts +++ b/packages/permission-log-controller/tests/PermissionLogController.test.ts @@ -3,27 +3,23 @@ import type { JsonRpcEngineReturnHandler, JsonRpcEngineNextCallback, } from '@metamask/json-rpc-engine'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; -import { - type PendingJsonRpcResponse, - type JsonRpcRequest, - PendingJsonRpcResponseStruct, -} from '@metamask/utils'; +import { PendingJsonRpcResponseStruct } from '@metamask/utils'; +import type { PendingJsonRpcResponse, JsonRpcRequest } from '@metamask/utils'; import { nanoid } from 'nanoid'; import { constants, getters, noop } from './helpers'; import { LOG_LIMIT, LOG_METHOD_TYPES } from '../src/enums'; -import { - PermissionLogController, - type Permission, - type PermissionLogControllerState, - type PermissionLogControllerMessenger, +import { PermissionLogController } from '../src/PermissionLogController'; +import type { + Permission, + PermissionLogControllerState, + PermissionLogControllerMessenger, } from '../src/PermissionLogController'; const { PERMS, RPC_REQUESTS } = getters; diff --git a/packages/permission-log-controller/tests/helpers.ts b/packages/permission-log-controller/tests/helpers.ts index eab0c88831e..1f01bfedf4b 100644 --- a/packages/permission-log-controller/tests/helpers.ts +++ b/packages/permission-log-controller/tests/helpers.ts @@ -1,4 +1,5 @@ -import { type Json, JsonRpcRequestStruct } from '@metamask/utils'; +import { JsonRpcRequestStruct } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; import deepFreeze from 'deep-freeze-strict'; import { CAVEAT_TYPES } from '../src/enums'; diff --git a/packages/phishing-controller/CHANGELOG.md b/packages/phishing-controller/CHANGELOG.md index b3575e5b3ae..4e15d2fc85d 100644 --- a/packages/phishing-controller/CHANGELOG.md +++ b/packages/phishing-controller/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add support for Monad network (`0x8f`) in token scanning ([#7237](https://github.com/MetaMask/core/pull/7237)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.1.0` to `^62.5.0` ([#7220](https://github.com/MetaMask/core/pull/7220), [#7236](https://github.com/MetaMask/core/pull/7236), [#7257](https://github.com/MetaMask/core/pull/7257), [#7289](https://github.com/MetaMask/core/pull/7289), [#7325](https://github.com/MetaMask/core/pull/7325)) + +## [16.1.0] + +### Added + +- Export `TokenScanCacheData` and `TokenScanResultType` to allow consumers to have a type to reference if grabbing values directly from the controller's state ([#7208](https://github.com/MetaMask/core/pull/7208)) + +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/transaction-controller` (^62.1.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [16.0.0] ### Added @@ -487,7 +510,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@16.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@16.1.0...HEAD +[16.1.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@16.0.0...@metamask/phishing-controller@16.1.0 [16.0.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@15.0.1...@metamask/phishing-controller@16.0.0 [15.0.1]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@15.0.0...@metamask/phishing-controller@15.0.1 [15.0.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@14.1.3...@metamask/phishing-controller@15.0.0 diff --git a/packages/phishing-controller/package.json b/packages/phishing-controller/package.json index bb9fde1fdeb..78c296ae7a7 100644 --- a/packages/phishing-controller/package.json +++ b/packages/phishing-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/phishing-controller", - "version": "16.0.0", + "version": "16.1.0", "description": "Maintains a periodically updated list of approved and unapproved website origins", "keywords": [ "MetaMask", @@ -51,6 +51,7 @@ "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.16.0", "@metamask/messenger": "^0.3.0", + "@metamask/transaction-controller": "^62.5.0", "@noble/hashes": "^1.8.0", "@types/punycode": "^2.1.0", "ethereum-cryptography": "^2.1.2", @@ -59,7 +60,6 @@ }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/transaction-controller": "^62.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -71,9 +71,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/transaction-controller": "^62.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/phishing-controller/src/BulkTokenScan.test.ts b/packages/phishing-controller/src/BulkTokenScan.test.ts index 1773fd75de4..9755d0218ce 100644 --- a/packages/phishing-controller/src/BulkTokenScan.test.ts +++ b/packages/phishing-controller/src/BulkTokenScan.test.ts @@ -1,26 +1,24 @@ import { safelyExecuteWithTimeout } from '@metamask/controller-utils'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import nock, { cleanAll } from 'nock'; import sinon from 'sinon'; -import type { PhishingControllerMessenger } from './PhishingController'; import { PhishingController, - type PhishingControllerOptions, SECURITY_ALERTS_BASE_URL, TOKEN_BULK_SCANNING_ENDPOINT, } from './PhishingController'; -import { - type BulkTokenScanRequest, - type TokenScanApiResponse, - TokenScanResultType, -} from './types'; +import type { + PhishingControllerMessenger, + PhishingControllerOptions, +} from './PhishingController'; +import { TokenScanResultType } from './types'; +import type { BulkTokenScanRequest, TokenScanApiResponse } from './types'; jest.mock('@metamask/controller-utils', () => ({ ...jest.requireActual('@metamask/controller-utils'), diff --git a/packages/phishing-controller/src/PathTrie.test.ts b/packages/phishing-controller/src/PathTrie.test.ts index 2b0af547803..3169a7f1cfa 100644 --- a/packages/phishing-controller/src/PathTrie.test.ts +++ b/packages/phishing-controller/src/PathTrie.test.ts @@ -4,9 +4,9 @@ import { deleteFromTrie, insertToTrie, isTerminal, - type PathTrie, matchedPathPrefix, } from './PathTrie'; +import type { PathTrie } from './PathTrie'; const emptyPathTrie: PathTrie = {}; diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index 51bc1c823c8..42a286794f7 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -1,10 +1,9 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { strict as assert } from 'assert'; import nock, { cleanAll, isDone, pendingMocks } from 'nock'; @@ -16,17 +15,19 @@ import { METAMASK_STALELIST_FILE, PhishingController, PHISHING_CONFIG_BASE_URL, - type PhishingControllerOptions, CLIENT_SIDE_DETECION_BASE_URL, C2_DOMAIN_BLOCKLIST_ENDPOINT, PHISHING_DETECTION_BASE_URL, PHISHING_DETECTION_SCAN_ENDPOINT, PHISHING_DETECTION_BULK_SCAN_ENDPOINT, - type BulkPhishingDetectionScanResponse, - type PhishingControllerMessenger, SECURITY_ALERTS_BASE_URL, ADDRESS_SCAN_ENDPOINT, } from './PhishingController'; +import type { + PhishingControllerOptions, + BulkPhishingDetectionScanResponse, + PhishingControllerMessenger, +} from './PhishingController'; import { createMockStateChangePayload, createMockTransaction, diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 61c27586e7d..24175c5a2f7 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -1,14 +1,14 @@ -import { - BaseController, - type StateMetadata, - type ControllerGetStateAction, - type ControllerStateChangeEvent, +import { BaseController } from '@metamask/base-controller'; +import type { + StateMetadata, + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; import { safelyExecute, safelyExecuteWithTimeout, } from '@metamask/controller-utils'; -import { type Messenger } from '@metamask/messenger'; +import type { Messenger } from '@metamask/messenger'; import type { TransactionControllerStateChangeEvent, TransactionMeta, @@ -16,27 +16,26 @@ import type { import type { Patch } from 'immer'; import { toASCII } from 'punycode/punycode.js'; -import { CacheManager, type CacheEntry } from './CacheManager'; -import { - type PathTrie, - convertListToTrie, - insertToTrie, - matchedPathPrefix, -} from './PathTrie'; +import { CacheManager } from './CacheManager'; +import type { CacheEntry } from './CacheManager'; +import { convertListToTrie, insertToTrie, matchedPathPrefix } from './PathTrie'; +import type { PathTrie } from './PathTrie'; import { PhishingDetector } from './PhishingDetector'; import { PhishingDetectorResultType, - type PhishingDetectorResult, - type PhishingDetectionScanResult, RecommendedAction, - type TokenScanCacheData, - type BulkTokenScanResponse, - type BulkTokenScanRequest, - type TokenScanApiResponse, - type AddressScanCacheData, - type AddressScanResult, AddressScanResultType, } from './types'; +import type { + PhishingDetectorResult, + PhishingDetectionScanResult, + TokenScanCacheData, + BulkTokenScanResponse, + BulkTokenScanRequest, + TokenScanApiResponse, + AddressScanCacheData, + AddressScanResult, +} from './types'; import { applyDiffs, fetchTimeNow, diff --git a/packages/phishing-controller/src/PhishingDetector.test.ts b/packages/phishing-controller/src/PhishingDetector.test.ts index 372f367e6f4..51edd5ed482 100644 --- a/packages/phishing-controller/src/PhishingDetector.test.ts +++ b/packages/phishing-controller/src/PhishingDetector.test.ts @@ -1,7 +1,5 @@ -import { - PhishingDetector, - type PhishingDetectorOptions, -} from './PhishingDetector'; +import { PhishingDetector } from './PhishingDetector'; +import type { PhishingDetectorOptions } from './PhishingDetector'; import { formatHostnameToUrl } from './tests/utils'; import { PhishingDetectorResultType } from './types'; import { sha256Hash } from './utils'; diff --git a/packages/phishing-controller/src/PhishingDetector.ts b/packages/phishing-controller/src/PhishingDetector.ts index b28ea3503f2..5b3d6584919 100644 --- a/packages/phishing-controller/src/PhishingDetector.ts +++ b/packages/phishing-controller/src/PhishingDetector.ts @@ -1,10 +1,9 @@ import { distance } from 'fastest-levenshtein'; -import { matchedPathPrefix, type PathTrie } from './PathTrie'; -import { - PhishingDetectorResultType, - type PhishingDetectorResult, -} from './types'; +import { matchedPathPrefix } from './PathTrie'; +import type { PathTrie } from './PathTrie'; +import { PhishingDetectorResultType } from './types'; +import type { PhishingDetectorResult } from './types'; import { domainPartsToDomain, domainPartsToFuzzyForm, diff --git a/packages/phishing-controller/src/index.ts b/packages/phishing-controller/src/index.ts index 234b3b1ab77..1e52e713a2e 100644 --- a/packages/phishing-controller/src/index.ts +++ b/packages/phishing-controller/src/index.ts @@ -8,6 +8,7 @@ export type { } from './PhishingDetector'; export { PhishingDetector } from './PhishingDetector'; export type { PhishingDetectionScanResult, AddressScanResult } from './types'; +export type { TokenScanCacheData, TokenScanResultType } from './types'; export { PhishingDetectorResultType, RecommendedAction, diff --git a/packages/phishing-controller/src/types.ts b/packages/phishing-controller/src/types.ts index df065ea54a2..77fea92dbd5 100644 --- a/packages/phishing-controller/src/types.ts +++ b/packages/phishing-controller/src/types.ts @@ -212,6 +212,7 @@ export const DEFAULT_CHAIN_ID_TO_NAME = { '0x2b74': 'abstract-testnet', '0x531': 'sei', '0x2eb': 'flow-evm', + '0x8f': 'monad', } as const; export type ChainIdToNameMap = typeof DEFAULT_CHAIN_ID_TO_NAME; diff --git a/packages/phishing-controller/src/utils.test.ts b/packages/phishing-controller/src/utils.test.ts index a4e411cbf77..e722714f2f0 100644 --- a/packages/phishing-controller/src/utils.test.ts +++ b/packages/phishing-controller/src/utils.test.ts @@ -1,11 +1,8 @@ import * as sinon from 'sinon'; -import { - ListKeys, - ListNames, - type PhishingListState, -} from './PhishingController'; -import { type TokenScanResultType } from './types'; +import { ListKeys, ListNames } from './PhishingController'; +import type { PhishingListState } from './PhishingController'; +import type { TokenScanResultType } from './types'; import { applyDiffs, buildCacheKey, diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index 2f1408ec99f..819999ba3c6 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -8,11 +8,8 @@ import type { PhishingDetectorList, PhishingDetectorConfiguration, } from './PhishingDetector'; -import { - DEFAULT_CHAIN_ID_TO_NAME, - type TokenScanCacheData, - type TokenScanResult, -} from './types'; +import { DEFAULT_CHAIN_ID_TO_NAME } from './types'; +import type { TokenScanCacheData, TokenScanResult } from './types'; const DEFAULT_TOLERANCE = 3; diff --git a/packages/polling-controller/CHANGELOG.md b/packages/polling-controller/CHANGELOG.md index 1406025da02..af1ffdfdf0d 100644 --- a/packages/polling-controller/CHANGELOG.md +++ b/packages/polling-controller/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7258](https://github.com/MetaMask/core/pull/7258)) + - The dependencies moved are: + - `@metamask/network-controller` (^27.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [16.0.0] ### Changed diff --git a/packages/polling-controller/package.json b/packages/polling-controller/package.json index 4045b94dbfb..b91a8a77f78 100644 --- a/packages/polling-controller/package.json +++ b/packages/polling-controller/package.json @@ -50,6 +50,7 @@ "dependencies": { "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.16.0", + "@metamask/network-controller": "^27.0.0", "@metamask/utils": "^11.8.1", "@types/uuid": "^8.3.0", "fast-json-stable-stringify": "^2.1.0", @@ -57,7 +58,6 @@ }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^26.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -68,9 +68,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/network-controller": "^26.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/polling-controller/src/BlockTrackerPollingController.test.ts b/packages/polling-controller/src/BlockTrackerPollingController.test.ts index 88321e80b97..927fb92d27e 100644 --- a/packages/polling-controller/src/BlockTrackerPollingController.test.ts +++ b/packages/polling-controller/src/BlockTrackerPollingController.test.ts @@ -1,8 +1,5 @@ -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MockAnyNamespace, -} from '@metamask/messenger'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { MockAnyNamespace } from '@metamask/messenger'; import type { NetworkClient } from '@metamask/network-controller'; import EventEmitter from 'events'; import { useFakeTimers } from 'sinon'; diff --git a/packages/polling-controller/src/StaticIntervalPollingController.test.ts b/packages/polling-controller/src/StaticIntervalPollingController.test.ts index 380465867e8..dead8c95764 100644 --- a/packages/polling-controller/src/StaticIntervalPollingController.test.ts +++ b/packages/polling-controller/src/StaticIntervalPollingController.test.ts @@ -1,8 +1,5 @@ -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MockAnyNamespace, -} from '@metamask/messenger'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { MockAnyNamespace } from '@metamask/messenger'; import { createDeferredPromise } from '@metamask/utils'; import { useFakeTimers } from 'sinon'; diff --git a/packages/preferences-controller/CHANGELOG.md b/packages/preferences-controller/CHANGELOG.md index 5d45652907a..7c4a397d990 100644 --- a/packages/preferences-controller/CHANGELOG.md +++ b/packages/preferences-controller/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/keyring-controller` (^25.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [22.0.0] ### Changed diff --git a/packages/preferences-controller/package.json b/packages/preferences-controller/package.json index 6f843071cd9..d98b64a3a32 100644 --- a/packages/preferences-controller/package.json +++ b/packages/preferences-controller/package.json @@ -50,11 +50,11 @@ "dependencies": { "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.16.0", + "@metamask/keyring-controller": "^25.0.0", "@metamask/messenger": "^0.3.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^25.0.0", "@metamask/utils": "^11.8.1", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", @@ -66,9 +66,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/keyring-controller": "^25.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/preferences-controller/src/PreferencesController.test.ts b/packages/preferences-controller/src/PreferencesController.test.ts index 69be355062f..510f33309e4 100644 --- a/packages/preferences-controller/src/PreferencesController.test.ts +++ b/packages/preferences-controller/src/PreferencesController.test.ts @@ -1,11 +1,10 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import { getDefaultKeyringState } from '@metamask/keyring-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { cloneDeep } from 'lodash'; diff --git a/packages/preferences-controller/src/PreferencesController.ts b/packages/preferences-controller/src/PreferencesController.ts index c2698ea15cd..a78e6cf38a9 100644 --- a/packages/preferences-controller/src/PreferencesController.ts +++ b/packages/preferences-controller/src/PreferencesController.ts @@ -1,7 +1,7 @@ -import { - BaseController, - type ControllerStateChangeEvent, - type ControllerGetStateAction, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerStateChangeEvent, + ControllerGetStateAction, } from '@metamask/base-controller'; import { toChecksumHexAddress } from '@metamask/controller-utils'; import type { diff --git a/packages/profile-metrics-controller/CHANGELOG.md b/packages/profile-metrics-controller/CHANGELOG.md new file mode 100644 index 00000000000..e382f369b7c --- /dev/null +++ b/packages/profile-metrics-controller/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0] + +### Added + +- Initial release ([#7194](https://github.com/MetaMask/core/pull/7194), [#7196](https://github.com/MetaMask/core/pull/7196), [#7263](https://github.com/MetaMask/core/pull/7263)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/profile-metrics-controller@1.0.0...HEAD +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/profile-metrics-controller@1.0.0 diff --git a/packages/profile-metrics-controller/LICENSE b/packages/profile-metrics-controller/LICENSE new file mode 100644 index 00000000000..7d002dced3a --- /dev/null +++ b/packages/profile-metrics-controller/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/profile-metrics-controller/README.md b/packages/profile-metrics-controller/README.md new file mode 100644 index 00000000000..8eafc692b85 --- /dev/null +++ b/packages/profile-metrics-controller/README.md @@ -0,0 +1,13 @@ +# `@metamask/profile-metrics-controller` + +## Installation + +`yarn add @metamask/profile-metrics-controller` + +or + +`npm install @metamask/profile-metrics-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/profile-metrics-controller/jest.config.js b/packages/profile-metrics-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/profile-metrics-controller/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/profile-metrics-controller/package.json b/packages/profile-metrics-controller/package.json new file mode 100644 index 00000000000..186e18f0330 --- /dev/null +++ b/packages/profile-metrics-controller/package.json @@ -0,0 +1,82 @@ +{ + "name": "@metamask/profile-metrics-controller", + "version": "1.0.0", + "description": "Sample package to illustrate best practices for controllers", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/profile-metrics-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/profile-metrics-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/profile-metrics-controller", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/accounts-controller": "^35.0.0", + "@metamask/base-controller": "^9.0.0", + "@metamask/controller-utils": "^11.16.0", + "@metamask/keyring-controller": "^25.0.0", + "@metamask/messenger": "^0.3.0", + "@metamask/polling-controller": "^16.0.0", + "@metamask/profile-sync-controller": "^27.0.0", + "@metamask/utils": "^11.8.1", + "async-mutex": "^0.5.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@metamask/keyring-internal-api": "^9.0.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "nock": "^13.3.1", + "sinon": "^9.2.4", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts new file mode 100644 index 00000000000..0d3b9174e36 --- /dev/null +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts @@ -0,0 +1,587 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import { ProfileMetricsController } from './ProfileMetricsController'; +import type { ProfileMetricsControllerMessenger } from './ProfileMetricsController'; +import type { + ProfileMetricsSubmitMetricsRequest, + AccountWithScopes, +} from './ProfileMetricsService'; + +/** + * Creates a mock InternalAccount object for testing purposes. + * + * @param address - The address of the mock account. + * @param withEntropy - Whether to include entropy information in the account options. Defaults to true. + * @returns A mock InternalAccount object. + */ +function createMockAccount( + address: string, + withEntropy = true, +): InternalAccount { + return { + id: `id-${address}`, + address, + options: withEntropy + ? { + entropy: { + id: `entropy-${address}`, + type: 'mnemonic', + derivationPath: '', + groupIndex: 0, + }, + } + : {}, + methods: [], + scopes: ['eip155:1'], + type: 'any:account', + metadata: { + keyring: { + type: 'Test Keyring', + }, + name: `Account ${address}`, + importTime: 1713153716, + }, + }; +} + +describe('ProfileMetricsController', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + describe('constructor subscriptions', () => { + describe('when KeyringController:unlock is published', () => { + it('starts polling', async () => { + await withController(async ({ controller, rootMessenger }) => { + const pollSpy = jest.spyOn(controller, 'startPolling'); + + rootMessenger.publish('KeyringController:unlock'); + + expect(pollSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('when `initialEnqueueCompleted` is false', () => { + it.each([{ assertUserOptedIn: true }, { assertUserOptedIn: false }])( + 'adds existing accounts to the queue when `assertUserOptedIn` is $assertUserOptedIn', + async ({ assertUserOptedIn }) => { + await withController( + { options: { assertUserOptedIn: () => assertUserOptedIn } }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'AccountsController:listAccounts', + () => { + return [ + createMockAccount('0xAccount1'), + createMockAccount('0xAccount2', false), + ]; + }, + ); + + rootMessenger.publish('KeyringController:unlock'); + // Wait for async operations to complete. + await Promise.resolve(); + + expect(controller.state.initialEnqueueCompleted).toBe(true); + expect(controller.state.syncQueue).toStrictEqual({ + 'entropy-0xAccount1': [ + { address: '0xAccount1', scopes: ['eip155:1'] }, + ], + null: [{ address: '0xAccount2', scopes: ['eip155:1'] }], + }); + }, + ); + }, + ); + }); + + describe('when `initialEnqueueCompleted` is true', () => { + it.each([{ assertUserOptedIn: true }, { assertUserOptedIn: false }])( + 'does not add existing accounts to the queue when `assertUserOptedIn` is $assertUserOptedIn', + async ({ assertUserOptedIn }) => { + await withController( + { + options: { + assertUserOptedIn: () => assertUserOptedIn, + state: { initialEnqueueCompleted: true }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'AccountsController:listAccounts', + () => { + return [ + createMockAccount('0xAccount1'), + createMockAccount('0xAccount2'), + ]; + }, + ); + + rootMessenger.publish('KeyringController:unlock'); + // Wait for async operations to complete. + await Promise.resolve(); + + expect(controller.state.initialEnqueueCompleted).toBe(true); + expect(controller.state.syncQueue).toStrictEqual({}); + }, + ); + }, + ); + }); + }); + + describe('when KeyringController:lock is published', () => { + it('stops polling', async () => { + await withController(async ({ controller, rootMessenger }) => { + const pollSpy = jest.spyOn(controller, 'stopAllPolling'); + + rootMessenger.publish('KeyringController:lock'); + + expect(pollSpy).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when AccountsController:accountAdded is published', () => { + describe.each([ + { assertUserOptedIn: true }, + { assertUserOptedIn: false }, + ])( + 'when assertUserOptedIn is $assertUserOptedIn', + ({ assertUserOptedIn }) => { + it('adds the new account to the sync queue if the account has an entropy source id', async () => { + await withController( + { options: { assertUserOptedIn: () => assertUserOptedIn } }, + async ({ controller, rootMessenger }) => { + const newAccount = createMockAccount('0xNewAccount'); + + rootMessenger.publish( + 'AccountsController:accountAdded', + newAccount, + ); + // Wait for async operations to complete. + await Promise.resolve(); + + expect(controller.state.syncQueue).toStrictEqual({ + 'entropy-0xNewAccount': [ + { address: '0xNewAccount', scopes: ['eip155:1'] }, + ], + }); + }, + ); + }); + + it('adds the new account to the sync queue under `null` if the account has no entropy source id', async () => { + await withController( + { options: { assertUserOptedIn: () => assertUserOptedIn } }, + async ({ controller, rootMessenger }) => { + const newAccount = createMockAccount('0xNewAccount', false); + + rootMessenger.publish( + 'AccountsController:accountAdded', + newAccount, + ); + // Wait for async operations to complete. + await Promise.resolve(); + + expect(controller.state.syncQueue).toStrictEqual({ + null: [{ address: '0xNewAccount', scopes: ['eip155:1'] }], + }); + }, + ); + }); + }, + ); + }); + + describe('when AccountsController:accountRemoved is published', () => { + it('removes the account from the sync queue if it exists there', async () => { + const accounts: Record = { + id1: [ + { address: '0xAccount1', scopes: ['eip155:1'] }, + { address: '0xAccount2', scopes: ['eip155:1'] }, + ], + id2: [{ address: '0xAccount3', scopes: ['eip155:1'] }], + }; + await withController( + { + options: { state: { syncQueue: accounts } }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.publish( + 'AccountsController:accountRemoved', + '0xAccount2', + ); + // Wait for async operations to complete. + await Promise.resolve(); + + expect(controller.state.syncQueue).toStrictEqual({ + id1: [{ address: '0xAccount1', scopes: ['eip155:1'] }], + id2: [{ address: '0xAccount3', scopes: ['eip155:1'] }], + }); + }, + ); + }); + + it('removes the key from the sync queue if it becomes empty after account removal', async () => { + const accounts: Record = { + id1: [{ address: '0xAccount1', scopes: ['eip155:1'] }], + id2: [{ address: '0xAccount2', scopes: ['eip155:1'] }], + }; + await withController( + { + options: { state: { syncQueue: accounts } }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.publish( + 'AccountsController:accountRemoved', + '0xAccount1', + ); + // Wait for async operations to complete. + await Promise.resolve(); + + expect(controller.state.syncQueue).toStrictEqual({ + id2: [{ address: '0xAccount2', scopes: ['eip155:1'] }], + }); + }, + ); + }); + + it('does nothing if the account is not in the sync queue', async () => { + const accounts: Record = { + id1: [{ address: '0xAccount1', scopes: ['eip155:1'] }], + }; + await withController( + { + options: { state: { syncQueue: accounts } }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.publish( + 'AccountsController:accountRemoved', + '0xAccount2', + ); + + expect(controller.state.syncQueue).toStrictEqual(accounts); + }, + ); + }); + }); + }); + + describe('_executePoll', () => { + describe('when the user has not opted in to profile metrics', () => { + it('does not process the sync queue', async () => { + const accounts: Record = { + id1: [{ address: '0xAccount1', scopes: ['eip155:1'] }], + }; + await withController( + { + options: { + assertUserOptedIn: () => false, + state: { syncQueue: accounts }, + }, + }, + async ({ controller, mockSubmitMetrics }) => { + await controller._executePoll(); + + expect(mockSubmitMetrics).not.toHaveBeenCalled(); + expect(controller.state.syncQueue).toStrictEqual(accounts); + }, + ); + }); + }); + + describe('when the user has opted in to profile metrics', () => { + it('processes the sync queue on each poll', async () => { + const accounts: Record = { + id1: [{ address: '0xAccount1', scopes: ['eip155:1'] }], + }; + await withController( + { + options: { state: { syncQueue: accounts } }, + }, + async ({ controller, getMetaMetricsId, mockSubmitMetrics }) => { + await controller._executePoll(); + + expect(mockSubmitMetrics).toHaveBeenCalledTimes(1); + expect(mockSubmitMetrics).toHaveBeenCalledWith({ + metametricsId: getMetaMetricsId(), + entropySourceId: 'id1', + accounts: [{ address: '0xAccount1', scopes: ['eip155:1'] }], + }); + expect(controller.state.syncQueue).toStrictEqual({}); + }, + ); + }); + + it('processes the sync queue in batches grouped by entropySourceId', async () => { + const accounts: Record = { + id1: [ + { address: '0xAccount1', scopes: ['eip155:1'] }, + { address: '0xAccount2', scopes: ['eip155:1'] }, + ], + id2: [{ address: '0xAccount3', scopes: ['eip155:1'] }], + null: [{ address: '0xAccount4', scopes: ['eip155:1'] }], + }; + await withController( + { + options: { state: { syncQueue: accounts } }, + }, + async ({ controller, getMetaMetricsId, mockSubmitMetrics }) => { + await controller._executePoll(); + + expect(mockSubmitMetrics).toHaveBeenCalledTimes(3); + expect(mockSubmitMetrics).toHaveBeenNthCalledWith(1, { + metametricsId: getMetaMetricsId(), + entropySourceId: 'id1', + accounts: [ + { address: '0xAccount1', scopes: ['eip155:1'] }, + { address: '0xAccount2', scopes: ['eip155:1'] }, + ], + }); + expect(mockSubmitMetrics).toHaveBeenNthCalledWith(2, { + metametricsId: getMetaMetricsId(), + entropySourceId: 'id2', + accounts: [{ address: '0xAccount3', scopes: ['eip155:1'] }], + }); + expect(mockSubmitMetrics).toHaveBeenNthCalledWith(3, { + metametricsId: getMetaMetricsId(), + entropySourceId: null, + accounts: [{ address: '0xAccount4', scopes: ['eip155:1'] }], + }); + expect(controller.state.syncQueue).toStrictEqual({}); + }, + ); + }); + + it('skips one of the batches if the :submitMetrics call fails, but continues processing the rest', async () => { + const accounts: Record = { + id1: [{ address: '0xAccount1', scopes: ['eip155:1'] }], + id2: [{ address: '0xAccount2', scopes: ['eip155:1'] }], + }; + await withController( + { + options: { state: { syncQueue: accounts } }, + }, + async ({ controller, getMetaMetricsId, mockSubmitMetrics }) => { + const consoleErrorSpy = jest.spyOn(console, 'error'); + mockSubmitMetrics.mockImplementationOnce(() => { + throw new Error('Network error'); + }); + + await controller._executePoll(); + + expect(mockSubmitMetrics).toHaveBeenCalledTimes(2); + expect(mockSubmitMetrics).toHaveBeenNthCalledWith(1, { + metametricsId: getMetaMetricsId(), + entropySourceId: 'id1', + accounts: [{ address: '0xAccount1', scopes: ['eip155:1'] }], + }); + expect(mockSubmitMetrics).toHaveBeenNthCalledWith(2, { + metametricsId: getMetaMetricsId(), + entropySourceId: 'id2', + accounts: [{ address: '0xAccount2', scopes: ['eip155:1'] }], + }); + expect(controller.state.syncQueue).toStrictEqual({ + id1: [{ address: '0xAccount1', scopes: ['eip155:1'] }], + }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to submit profile metrics for entropy source ID id1:', + expect.any(Error), + ); + }, + ); + }); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + Object { + "initialEnqueueCompleted": false, + } + `); + }); + }); + + it('includes expected state in state logs', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + Object { + "initialEnqueueCompleted": false, + "syncQueue": Object {}, + } + `); + }); + }); + + it('persists expected state', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + Object { + "initialEnqueueCompleted": false, + "syncQueue": Object {}, + } + `); + }); + }); + + it('exposes expected state to UI', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(`Object {}`); + }); + }); + }); +}); + +/** + * The type of the messenger populated with all external actions and events + * required by the controller under test. + */ +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * The callback that `withController` calls. + */ +type WithControllerCallback = (payload: { + controller: ProfileMetricsController; + rootMessenger: RootMessenger; + messenger: ProfileMetricsControllerMessenger; + assertUserOptedIn: jest.Mock; + getMetaMetricsId: jest.Mock; + mockSubmitMetrics: jest.Mock< + Promise, + [ProfileMetricsSubmitMetricsRequest] + >; +}) => Promise | ReturnValue; + +/** + * The options bag that `withController` takes. + */ +type WithControllerOptions = { + options: Partial[0]>; +}; + +/** + * Constructs the messenger populated with all external actions and events + * required by the controller under test. + * + * @returns The root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the controller under test. + * + * @param rootMessenger - The root messenger, with all external actions and + * events required by the controller's messenger. + * @returns The controller-specific messenger. + */ +function getMessenger( + rootMessenger: RootMessenger, +): ProfileMetricsControllerMessenger { + const messenger: ProfileMetricsControllerMessenger = new Messenger({ + namespace: 'ProfileMetricsController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger, + actions: [ + 'AccountsController:listAccounts', + 'ProfileMetricsService:submitMetrics', + ], + events: [ + 'KeyringController:unlock', + 'KeyringController:lock', + 'AccountsController:accountAdded', + 'AccountsController:accountRemoved', + ], + }); + return messenger; +} + +/** + * Wrap tests for the controller under test by ensuring that the controller is + * created ahead of time and then safely destroyed afterward as needed. + * + * @param args - Either a function, or an options bag + a function. The options + * bag contains arguments for the controller constructor. All constructor + * arguments are optional and will be filled in with defaults in as needed + * (including `messenger`). The function is called with the new + * controller, root messenger, and controller messenger. + * @returns The same return value as the given function. + */ +async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + const mockSubmitMetrics = jest.fn(); + const mockAssertUserOptedIn = jest.fn().mockReturnValue(true); + const mockGetMetaMetricsId = jest.fn().mockReturnValue('test-metrics-id'); + + const rootMessenger = getRootMessenger(); + rootMessenger.registerActionHandler( + 'ProfileMetricsService:submitMetrics', + mockSubmitMetrics, + ); + + const messenger = getMessenger(rootMessenger); + const controller = new ProfileMetricsController({ + messenger, + assertUserOptedIn: mockAssertUserOptedIn, + getMetaMetricsId: mockGetMetaMetricsId, + ...options, + }); + + return await testFunction({ + controller, + rootMessenger, + messenger, + assertUserOptedIn: mockAssertUserOptedIn, + getMetaMetricsId: mockGetMetaMetricsId, + mockSubmitMetrics, + }); +} diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.ts new file mode 100644 index 00000000000..d4f17114a27 --- /dev/null +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.ts @@ -0,0 +1,364 @@ +import type { + AccountsControllerAccountAddedEvent, + AccountsControllerListAccountsAction, + AccountsControllerAccountRemovedEvent, +} from '@metamask/accounts-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import type { + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import { Mutex } from 'async-mutex'; + +import type { ProfileMetricsServiceMethodActions } from '.'; +import type { AccountWithScopes } from './ProfileMetricsService'; + +/** + * The name of the {@link ProfileMetricsController}, used to namespace the + * controller's actions and events and to namespace the controller's state data + * when composed with other controllers. + */ +export const controllerName = 'ProfileMetricsController'; + +/** + * Describes the shape of the state object for {@link ProfileMetricsController}. + */ +export type ProfileMetricsControllerState = { + /** + * Whether existing accounts have been added + * to the queue. + */ + initialEnqueueCompleted: boolean; + /** + * The queue of accounts to be synced. + * Each key is an entropy source ID, and each value is an array of account + * addresses associated with that entropy source. Accounts with no entropy + * source ID are grouped under the key "null". + */ + syncQueue: Record; +}; + +/** + * The metadata for each property in {@link ProfileMetricsControllerState}. + */ +const profileMetricsControllerMetadata = { + initialEnqueueCompleted: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: false, + }, + syncQueue: { + persist: true, + includeInDebugSnapshot: false, + includeInStateLogs: true, + usedInUi: false, + }, +} satisfies StateMetadata; + +/** + * Constructs the default {@link ProfileMetricsController} state. This allows + * consumers to provide a partial state object when initializing the controller + * and also helps in constructing complete state objects for this controller in + * tests. + * + * @returns The default {@link ProfileMetricsController} state. + */ +export function getDefaultProfileMetricsControllerState(): ProfileMetricsControllerState { + return { + initialEnqueueCompleted: false, + syncQueue: {}, + }; +} + +const MESSENGER_EXPOSED_METHODS = [] as const; + +/** + * Retrieves the state of the {@link ProfileMetricsController}. + */ +export type ProfileMetricsControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + ProfileMetricsControllerState +>; + +/** + * Actions that {@link ProfileMetricsControllerMessenger} exposes to other consumers. + */ +export type ProfileMetricsControllerActions = + | ProfileMetricsControllerGetStateAction + | ProfileMetricsServiceMethodActions; + +/** + * Actions from other messengers that {@link ProfileMetricsControllerMessenger} calls. + */ +type AllowedActions = + | ProfileMetricsServiceMethodActions + | AccountsControllerListAccountsAction; + +/** + * Published when the state of {@link ProfileMetricsController} changes. + */ +export type ProfileMetricsControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + ProfileMetricsControllerState + >; + +/** + * Events that {@link ProfileMetricsControllerMessenger} exposes to other consumers. + */ +export type ProfileMetricsControllerEvents = + ProfileMetricsControllerStateChangeEvent; + +/** + * Events from other messengers that {@link ProfileMetricsControllerMessenger} subscribes + * to. + */ +type AllowedEvents = + | KeyringControllerUnlockEvent + | KeyringControllerLockEvent + | AccountsControllerAccountAddedEvent + | AccountsControllerAccountRemovedEvent; + +/** + * The messenger restricted to actions and events accessed by + * {@link ProfileMetricsController}. + */ +export type ProfileMetricsControllerMessenger = Messenger< + typeof controllerName, + ProfileMetricsControllerActions | AllowedActions, + ProfileMetricsControllerEvents | AllowedEvents +>; + +export class ProfileMetricsController extends StaticIntervalPollingController()< + typeof controllerName, + ProfileMetricsControllerState, + ProfileMetricsControllerMessenger +> { + readonly #mutex = new Mutex(); + + readonly #assertUserOptedIn: () => boolean; + + readonly #getMetaMetricsId: () => string; + + /** + * Constructs a new {@link ProfileMetricsController}. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this controller. + * @param args.state - The desired state with which to initialize this + * controller. Missing properties will be filled in with defaults. + * @param args.assertUserOptedIn - A function that asserts whether the user has + * opted in to user profile features. If the user has not opted in, sync + * operations will be no-ops. + * @param args.getMetaMetricsId - A function that returns the MetaMetrics ID + * of the user. + * @param args.interval - The interval, in milliseconds, at which the controller will + * attempt to send user profile data. Defaults to 10 seconds. + */ + constructor({ + messenger, + state, + assertUserOptedIn, + getMetaMetricsId, + interval = 10 * 1000, + }: { + messenger: ProfileMetricsControllerMessenger; + state?: Partial; + interval?: number; + assertUserOptedIn: () => boolean; + getMetaMetricsId: () => string; + }) { + super({ + messenger, + metadata: profileMetricsControllerMetadata, + name: controllerName, + state: { + ...getDefaultProfileMetricsControllerState(), + ...state, + }, + }); + + this.#assertUserOptedIn = assertUserOptedIn; + this.#getMetaMetricsId = getMetaMetricsId; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + this.messenger.subscribe('KeyringController:unlock', () => { + this.startPolling(null); + this.#queueFirstSyncIfNeeded().catch(console.error); + }); + + this.messenger.subscribe('KeyringController:lock', () => { + this.stopAllPolling(); + }); + + this.messenger.subscribe('AccountsController:accountAdded', (account) => { + this.#addAccountToQueue(account).catch(console.error); + }); + + this.messenger.subscribe('AccountsController:accountRemoved', (account) => { + this.#removeAccountFromQueue(account).catch(console.error); + }); + + this.setIntervalLength(interval); + } + + /** + * Execute a single poll to sync user profile data. + * + * The queued accounts are sent to the ProfileMetricsService, and the sync + * queue is cleared. This operation is mutexed to prevent concurrent + * executions. + * + * @returns A promise that resolves when the poll is complete. + */ + async _executePoll(): Promise { + await this.#mutex.runExclusive(async () => { + if (!this.#assertUserOptedIn()) { + return; + } + for (const [entropySourceId, accounts] of Object.entries( + this.state.syncQueue, + )) { + try { + await this.messenger.call('ProfileMetricsService:submitMetrics', { + metametricsId: this.#getMetaMetricsId(), + entropySourceId: + entropySourceId === 'null' ? null : entropySourceId, + accounts, + }); + this.update((state) => { + delete state.syncQueue[entropySourceId]; + }); + } catch (error) { + // We want to log the error but continue processing other batches. + console.error( + `Failed to submit profile metrics for entropy source ID ${entropySourceId}:`, + error, + ); + } + } + }); + } + + /** + * Add existing accounts to the sync queue if it has not been done yet. + * + * This method ensures that the first sync is only executed once, + * and only if the user has opted in to user profile features. + */ + async #queueFirstSyncIfNeeded() { + await this.#mutex.runExclusive(async () => { + if (this.state.initialEnqueueCompleted) { + return; + } + const newGroupedAccounts = groupAccountsByEntropySourceId( + this.messenger.call('AccountsController:listAccounts'), + ); + this.update((state) => { + for (const key of Object.keys(newGroupedAccounts)) { + if (!state.syncQueue[key]) { + state.syncQueue[key] = []; + } + state.syncQueue[key].push(...newGroupedAccounts[key]); + } + state.initialEnqueueCompleted = true; + }); + }); + } + + /** + * Queue the given account to be synced at the next poll. + * + * @param account - The account to sync. + */ + async #addAccountToQueue(account: InternalAccount) { + await this.#mutex.runExclusive(async () => { + this.update((state) => { + const entropySourceId = getAccountEntropySourceId(account) || 'null'; + if (!state.syncQueue[entropySourceId]) { + state.syncQueue[entropySourceId] = []; + } + state.syncQueue[entropySourceId].push({ + address: account.address, + scopes: account.scopes, + }); + }); + }); + } + + /** + * Remove the given account from the sync queue. + * + * @param account - The account address to remove. + */ + async #removeAccountFromQueue(account: string) { + await this.#mutex.runExclusive(async () => { + this.update((state) => { + for (const [entropySourceId, groupedAddresses] of Object.entries( + state.syncQueue, + )) { + const index = groupedAddresses.findIndex( + ({ address }) => address === account, + ); + if (index === -1) { + continue; + } + groupedAddresses.splice(index, 1); + if (groupedAddresses.length === 0) { + delete state.syncQueue[entropySourceId]; + } + break; + } + }); + }); + } +} + +/** + * Retrieves the entropy source ID from the given account, if it exists. + * + * @param account - The account from which to retrieve the entropy source ID. + * @returns The entropy source ID, or null if it does not exist. + */ +function getAccountEntropySourceId(account: InternalAccount): string | null { + if (account.options.entropy?.type === 'mnemonic') { + return account.options.entropy.id; + } + return null; +} + +/** + * Groups accounts by their entropy source ID. + * + * @param accounts - The accounts to group. + * @returns An object where each key is an entropy source ID and each value is + * an array of account addresses associated with that entropy source ID. + */ +function groupAccountsByEntropySourceId( + accounts: InternalAccount[], +): Record { + return accounts.reduce( + (result: Record, account) => { + const entropySourceId = getAccountEntropySourceId(account); + const key = entropySourceId || 'null'; + if (!result[key]) { + result[key] = []; + } + result[key].push({ address: account.address, scopes: account.scopes }); + return result; + }, + {}, + ); +} diff --git a/packages/profile-metrics-controller/src/ProfileMetricsService-method-action-types.ts b/packages/profile-metrics-controller/src/ProfileMetricsService-method-action-types.ts new file mode 100644 index 00000000000..7d84bb447ab --- /dev/null +++ b/packages/profile-metrics-controller/src/ProfileMetricsService-method-action-types.ts @@ -0,0 +1,23 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { ProfileMetricsService } from './ProfileMetricsService'; + +/** + * Submit metrics to the API. + * + * @param data - The data to send in the metrics update request. + * @returns The response from the API. + */ +export type ProfileMetricsServiceSubmitMetricsAction = { + type: `ProfileMetricsService:submitMetrics`; + handler: ProfileMetricsService['submitMetrics']; +}; + +/** + * Union of all ProfileMetricsService action types. + */ +export type ProfileMetricsServiceMethodActions = + ProfileMetricsServiceSubmitMetricsAction; diff --git a/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts b/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts new file mode 100644 index 00000000000..2b50fe91bba --- /dev/null +++ b/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts @@ -0,0 +1,388 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import { SDK } from '@metamask/profile-sync-controller'; +import nock from 'nock'; +import { useFakeTimers } from 'sinon'; +import type { SinonFakeTimers } from 'sinon'; + +import { ProfileMetricsService } from '.'; +import type { + ProfileMetricsSubmitMetricsRequest, + ProfileMetricsServiceMessenger, +} from '.'; +import { getAuthUrl } from './ProfileMetricsService'; +import { HttpError } from '../../controller-utils/src/util'; + +const defaultBaseEndpoint = getAuthUrl(SDK.Env.DEV); + +/** + * Creates a mock request object for testing purposes. + * + * @param override - Optional properties to override in the mock request. + * @returns A mock request object. + */ +function createMockRequest( + override?: Partial, +): ProfileMetricsSubmitMetricsRequest { + return { + metametricsId: 'mock-meta-metrics-id', + entropySourceId: 'mock-entropy-source-id', + accounts: [{ address: '0xMockAccountAddress1', scopes: ['eip155:1'] }], + ...override, + }; +} + +describe('ProfileMetricsService', () => { + let clock: SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + describe('constructor', () => { + it('throws when an invalid env is selected', () => { + expect( + () => + new ProfileMetricsService({ + fetch, + messenger: getMessenger(getRootMessenger()), + // @ts-expect-error Testing invalid env + env: 'invalid-env', + }), + ).toThrow('invalid environment configuration'); + }); + }); + + describe('ProfileMetricsService:submitMetrics', () => { + it('resolves when there is a successful response from the API and the accounts have an entropy source id', async () => { + nock(defaultBaseEndpoint) + .put('/profile/accounts') + .reply(200, { + data: { + success: true, + }, + }); + const { rootMessenger } = getService(); + + const submitMetricsResponse = await rootMessenger.call( + 'ProfileMetricsService:submitMetrics', + createMockRequest(), + ); + + expect(submitMetricsResponse).toBeUndefined(); + }); + + it('resolves when there is a successful response from the API and the accounts do not have an entropy source id', async () => { + nock(defaultBaseEndpoint) + .put('/profile/accounts') + .reply(200, { + data: { + success: true, + }, + }); + const { rootMessenger } = getService(); + + const request = createMockRequest({ entropySourceId: null }); + + const submitMetricsResponse = await rootMessenger.call( + 'ProfileMetricsService:submitMetrics', + request, + ); + + expect(submitMetricsResponse).toBeUndefined(); + }); + + it('calls onDegraded listeners if the request takes longer than 5 seconds to resolve', async () => { + nock(defaultBaseEndpoint) + .put('/profile/accounts') + .reply(200, () => { + clock.tick(6000); + return { + data: { + success: true, + }, + }; + }); + const { service, rootMessenger } = getService(); + const onDegradedListener = jest.fn(); + service.onDegraded(onDegradedListener); + + await rootMessenger.call( + 'ProfileMetricsService:submitMetrics', + createMockRequest(), + ); + + expect(onDegradedListener).toHaveBeenCalled(); + }); + + it('allows the degradedThreshold to be changed', async () => { + nock(defaultBaseEndpoint) + .put('/profile/accounts') + .reply(200, () => { + clock.tick(1000); + return { + data: { + success: true, + }, + }; + }); + const { service, rootMessenger } = getService({ + options: { + policyOptions: { degradedThreshold: 500 }, + }, + }); + const onDegradedListener = jest.fn(); + service.onDegraded(onDegradedListener); + + await rootMessenger.call( + 'ProfileMetricsService:submitMetrics', + createMockRequest(), + ); + + expect(onDegradedListener).toHaveBeenCalled(); + }); + + it('attempts a request that responds with non-200 up to 4 times, throwing if it never succeeds', async () => { + nock(defaultBaseEndpoint).put('/profile/accounts').times(4).reply(500); + const { service, rootMessenger } = getService(); + service.onRetry(clock.next); + + await expect( + rootMessenger.call( + 'ProfileMetricsService:submitMetrics', + createMockRequest(), + ), + ).rejects.toThrow( + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, + ); + }); + + it('calls onDegraded listeners when the maximum number of retries is exceeded', async () => { + nock(defaultBaseEndpoint).put('/profile/accounts').times(4).reply(500); + const { service, rootMessenger } = getService(); + service.onRetry(clock.next); + const onDegradedListener = jest.fn(); + service.onDegraded(onDegradedListener); + + await expect( + rootMessenger.call( + 'ProfileMetricsService:submitMetrics', + createMockRequest(), + ), + ).rejects.toThrow( + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, + ); + expect(onDegradedListener).toHaveBeenCalled(); + }); + + it('intercepts requests and throws a circuit break error after the 4th failed attempt, running onBreak listeners', async () => { + nock(defaultBaseEndpoint).put('/profile/accounts').times(12).reply(500); + const { service, rootMessenger } = getService(); + service.onRetry(clock.next); + const onBreakListener = jest.fn(); + service.onBreak(onBreakListener); + + // Should make 4 requests + await expect( + rootMessenger.call( + 'ProfileMetricsService:submitMetrics', + createMockRequest(), + ), + ).rejects.toThrow( + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, + ); + // Should make 4 requests + await expect( + rootMessenger.call( + 'ProfileMetricsService:submitMetrics', + createMockRequest(), + ), + ).rejects.toThrow( + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, + ); + // Should make 4 requests + await expect( + rootMessenger.call( + 'ProfileMetricsService:submitMetrics', + createMockRequest(), + ), + ).rejects.toThrow( + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, + ); + // Should not make an additional request (we only mocked 12 requests + // above) + await expect( + rootMessenger.call( + 'ProfileMetricsService:submitMetrics', + createMockRequest(), + ), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + expect(onBreakListener).toHaveBeenCalledWith({ + error: new HttpError( + 500, + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, + ), + }); + }); + + it('resumes requests after the circuit break duration passes, returning the API response if the request ultimately succeeds', async () => { + const circuitBreakDuration = 5_000; + nock(defaultBaseEndpoint) + .put('/profile/accounts') + .times(12) + .reply(500) + .put('/profile/accounts') + .reply(200, { + data: { + success: true, + }, + }); + const { service, rootMessenger } = getService({ + options: { + policyOptions: { circuitBreakDuration }, + }, + }); + service.onRetry(clock.next); + + await expect( + rootMessenger.call( + 'ProfileMetricsService:submitMetrics', + createMockRequest(), + ), + ).rejects.toThrow( + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, + ); + await expect( + rootMessenger.call( + 'ProfileMetricsService:submitMetrics', + createMockRequest(), + ), + ).rejects.toThrow( + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, + ); + await expect( + rootMessenger.call( + 'ProfileMetricsService:submitMetrics', + createMockRequest(), + ), + ).rejects.toThrow( + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, + ); + await expect( + rootMessenger.call( + 'ProfileMetricsService:submitMetrics', + createMockRequest(), + ), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + await clock.tickAsync(circuitBreakDuration); + const submitMetricsResponse = + await service.submitMetrics(createMockRequest()); + expect(submitMetricsResponse).toBeUndefined(); + }); + }); + + describe('submitMetrics', () => { + it('does the same thing as the messenger action', async () => { + nock(defaultBaseEndpoint) + .put('/profile/accounts') + .reply(200, { + data: { + success: true, + }, + }); + const { service } = getService(); + + const submitMetricsResponse = + await service.submitMetrics(createMockRequest()); + + expect(submitMetricsResponse).toBeUndefined(); + }); + }); +}); + +/** + * The type of the messenger populated with all external actions and events + * required by the service under test. + */ +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * Constructs the messenger populated with all external actions and events + * required by the service under test. + * + * @returns The root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the service under test. + * + * @param rootMessenger - The root messenger, with all external actions and + * events required by the controller's messenger. + * @returns The service-specific messenger. + */ +function getMessenger( + rootMessenger: RootMessenger, +): ProfileMetricsServiceMessenger { + const serviceMessenger: ProfileMetricsServiceMessenger = new Messenger({ + namespace: 'ProfileMetricsService', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger: serviceMessenger, + actions: ['AuthenticationController:getBearerToken'], + }); + return serviceMessenger; +} + +/** + * Constructs the service under test. + * + * @param args - The arguments to this function. + * @param args.options - The options that the service constructor takes. All are + * optional and will be filled in with defaults in as needed (including + * `messenger`). + * @returns The new service, root messenger, and service messenger. + */ +function getService({ + options = {}, +}: { + options?: Partial[0]>; +} = {}): { + service: ProfileMetricsService; + rootMessenger: RootMessenger; + messenger: ProfileMetricsServiceMessenger; +} { + const rootMessenger = getRootMessenger(); + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + async () => 'mock-bearer-token', + ); + + const messenger = getMessenger(rootMessenger); + const service = new ProfileMetricsService({ + fetch, + messenger, + ...options, + }); + + return { service, rootMessenger, messenger }; +} diff --git a/packages/profile-metrics-controller/src/ProfileMetricsService.ts b/packages/profile-metrics-controller/src/ProfileMetricsService.ts new file mode 100644 index 00000000000..0067f553329 --- /dev/null +++ b/packages/profile-metrics-controller/src/ProfileMetricsService.ts @@ -0,0 +1,234 @@ +import type { + CreateServicePolicyOptions, + ServicePolicy, +} from '@metamask/controller-utils'; +import { createServicePolicy, HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import { SDK } from '@metamask/profile-sync-controller'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; + +import type { ProfileMetricsServiceMethodActions } from '.'; + +// === GENERAL === + +/** + * The name of the {@link ProfileMetricsService}, used to namespace the + * service's actions and events. + */ +export const serviceName = 'ProfileMetricsService'; + +/** + * An account address along with its associated scopes. + */ +export type AccountWithScopes = { + address: string; + scopes: `${string}:${string}`[]; +}; + +/** + * The shape of the request object for submitting metrics. + */ +export type ProfileMetricsSubmitMetricsRequest = { + metametricsId: string; + entropySourceId?: string | null; + accounts: AccountWithScopes[]; +}; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = ['submitMetrics'] as const; + +/** + * Actions that {@link ProfileMetricsService} exposes to other consumers. + */ +export type ProfileMetricsServiceActions = ProfileMetricsServiceMethodActions; + +/** + * Actions from other messengers that {@link ProfileMetricsService} calls. + */ +type AllowedActions = + AuthenticationController.AuthenticationControllerGetBearerToken; + +/** + * Events that {@link ProfileMetricsService} exposes to other consumers. + */ +export type ProfileMetricsServiceEvents = never; + +/** + * Events from other messengers that {@link ProfileMetricsService} subscribes + * to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link ProfileMetricsService}. + */ +export type ProfileMetricsServiceMessenger = Messenger< + typeof serviceName, + ProfileMetricsServiceActions | AllowedActions, + ProfileMetricsServiceEvents | AllowedEvents +>; + +// === SERVICE DEFINITION === + +export class ProfileMetricsService { + /** + * The name of the service. + */ + readonly name: typeof serviceName; + + /** + * The messenger suited for this service. + */ + readonly #messenger: ConstructorParameters< + typeof ProfileMetricsService + >[0]['messenger']; + + /** + * A function that can be used to make an HTTP request. + */ + readonly #fetch: ConstructorParameters< + typeof ProfileMetricsService + >[0]['fetch']; + + /** + * The policy that wraps the request. + * + * @see {@link createServicePolicy} + */ + readonly #policy: ServicePolicy; + + /** + * The API base URL environment. + */ + readonly #baseURL: string; + + /** + * Constructs a new ProfileMetricsService object. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this service. + * @param args.fetch - A function that can be used to make an HTTP request. If + * your JavaScript environment supports `fetch` natively, you'll probably want + * to pass that; otherwise you can pass an equivalent (such as `fetch` via + * `node-fetch`). + * @param args.policyOptions - Options to pass to `createServicePolicy`, which + * is used to wrap each request. See {@link CreateServicePolicyOptions}. + * @param args.env - The environment to determine the correct API endpoints. + */ + constructor({ + messenger, + fetch: fetchFunction, + policyOptions = {}, + env = SDK.Env.DEV, + }: { + messenger: ProfileMetricsServiceMessenger; + fetch: typeof fetch; + policyOptions?: CreateServicePolicyOptions; + env?: SDK.Env; + }) { + this.name = serviceName; + this.#messenger = messenger; + this.#fetch = fetchFunction; + this.#policy = createServicePolicy(policyOptions); + this.#baseURL = getAuthUrl(env); + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Registers a handler that will be called after a request returns a non-500 + * response, causing a retry. Primarily useful in tests where timers are being + * mocked. + * + * @param listener - The handler to be called. + * @returns An object that can be used to unregister the handler. See + * {@link CockatielEvent}. + * @see {@link createServicePolicy} + */ + onRetry(listener: Parameters[0]) { + return this.#policy.onRetry(listener); + } + + /** + * Registers a handler that will be called after a set number of retry rounds + * prove that requests to the API endpoint consistently return a 5xx response. + * + * @param listener - The handler to be called. + * @returns An object that can be used to unregister the handler. See + * {@link CockatielEvent}. + * @see {@link createServicePolicy} + */ + onBreak(listener: Parameters[0]) { + return this.#policy.onBreak(listener); + } + + /** + * Registers a handler that will be called under one of two circumstances: + * + * 1. After a set number of retries prove that requests to the API + * consistently result in one of the following failures: + * 1. A connection initiation error + * 2. A connection reset error + * 3. A timeout error + * 4. A non-JSON response + * 5. A 502, 503, or 504 response + * 2. After a successful request is made to the API, but the response takes + * longer than a set duration to return. + * + * @param listener - The handler to be called. + * @returns An object that can be used to unregister the handler. See + * {@link CockatielEvent}. + */ + onDegraded(listener: Parameters[0]) { + return this.#policy.onDegraded(listener); + } + + /** + * Submit metrics to the API. + * + * @param data - The data to send in the metrics update request. + * @returns The response from the API. + */ + async submitMetrics(data: ProfileMetricsSubmitMetricsRequest): Promise { + const authToken = await this.#messenger.call( + 'AuthenticationController:getBearerToken', + data.entropySourceId || undefined, + ); + await this.#policy.execute(async () => { + const url = new URL(`${this.#baseURL}/profile/accounts`); + const localResponse = await this.#fetch(url, { + method: 'PUT', + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + metametrics_id: data.metametricsId, + accounts: data.accounts, + }), + }); + if (!localResponse.ok) { + throw new HttpError( + localResponse.status, + `Fetching '${url.toString()}' failed with status '${localResponse.status}'`, + ); + } + return localResponse; + }); + } +} + +/** + * Returns the base URL for the given environment. + * + * @param env - The environment to get the URL for. + * @returns The base URL for the environment. + */ +export function getAuthUrl(env: SDK.Env): string { + return `${SDK.getEnvUrls(env).authApiUrl}/api/v2`; +} diff --git a/packages/profile-metrics-controller/src/index.ts b/packages/profile-metrics-controller/src/index.ts new file mode 100644 index 00000000000..a790d88548f --- /dev/null +++ b/packages/profile-metrics-controller/src/index.ts @@ -0,0 +1,20 @@ +export type { + ProfileMetricsControllerActions, + ProfileMetricsControllerEvents, + ProfileMetricsControllerGetStateAction, + ProfileMetricsControllerMessenger, + ProfileMetricsControllerState, + ProfileMetricsControllerStateChangeEvent, +} from './ProfileMetricsController'; +export { + ProfileMetricsController, + getDefaultProfileMetricsControllerState, +} from './ProfileMetricsController'; +export type { + ProfileMetricsServiceActions, + ProfileMetricsServiceEvents, + ProfileMetricsServiceMessenger, + ProfileMetricsSubmitMetricsRequest, +} from './ProfileMetricsService'; +export { ProfileMetricsService, serviceName } from './ProfileMetricsService'; +export type { ProfileMetricsServiceMethodActions } from './ProfileMetricsService-method-action-types'; diff --git a/packages/profile-metrics-controller/tsconfig.build.json b/packages/profile-metrics-controller/tsconfig.build.json new file mode 100644 index 00000000000..31d19fe3b86 --- /dev/null +++ b/packages/profile-metrics-controller/tsconfig.build.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../../packages/accounts-controller/tsconfig.build.json" }, + { "path": "../../packages/base-controller/tsconfig.build.json" }, + { "path": "../../packages/controller-utils/tsconfig.build.json" }, + { "path": "../../packages/keyring-controller/tsconfig.build.json" }, + { "path": "../../packages/messenger/tsconfig.build.json" }, + { "path": "../../packages/polling-controller/tsconfig.build.json" }, + { "path": "../../packages/profile-sync-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/profile-metrics-controller/tsconfig.json b/packages/profile-metrics-controller/tsconfig.json new file mode 100644 index 00000000000..9b01f9256c0 --- /dev/null +++ b/packages/profile-metrics-controller/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../../packages/accounts-controller" }, + { "path": "../../packages/base-controller" }, + { "path": "../../packages/controller-utils" }, + { "path": "../../packages/keyring-controller" }, + { "path": "../../packages/messenger" }, + { "path": "../../packages/polling-controller" }, + { "path": "../../packages/profile-sync-controller" } + ], + "include": ["../../types", "./src"], + /** + * Here we ensure that TypeScript resolves `@metamask/*` imports to the + * uncompiled source code for packages that live in this repo. + * + * NOTE: This must be synchronized with the `moduleNameMapper` option in + * `jest.config.packages.js`. + * + * NOTE 2: This is not necessary when copying this package to `packages/`. + */ + "paths": { + "@metamask/*": ["../../packages/*/src", "../*/src"] + } +} diff --git a/packages/profile-metrics-controller/typedoc.json b/packages/profile-metrics-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/profile-metrics-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index aa11423e831..1cb4437d43c 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/address-book-controller` (^7.0.1) + - `@metamask/keyring-controller` (^25.0.0) + - `@metamask/snaps-controllers` (^14.0.1) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [27.0.0] ### Changed diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index bc8de6a7704..49dbf907c04 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -101,8 +101,11 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/address-book-controller": "^7.0.1", "@metamask/base-controller": "^9.0.0", + "@metamask/keyring-controller": "^25.0.0", "@metamask/messenger": "^0.3.0", + "@metamask/snaps-controllers": "^14.0.1", "@metamask/snaps-sdk": "^9.0.0", "@metamask/snaps-utils": "^11.0.0", "@metamask/utils": "^11.8.1", @@ -115,13 +118,10 @@ "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", - "@metamask/address-book-controller": "^7.0.1", "@metamask/auto-changelog": "^3.4.4", "@metamask/keyring-api": "^21.0.0", - "@metamask/keyring-controller": "^25.0.0", "@metamask/keyring-internal-api": "^9.0.0", "@metamask/providers": "^22.1.0", - "@metamask/snaps-controllers": "^14.0.1", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -136,10 +136,7 @@ "webextension-polyfill": "^0.12.0" }, "peerDependencies": { - "@metamask/address-book-controller": "^7.0.1", - "@metamask/keyring-controller": "^25.0.0", "@metamask/providers": "^22.0.0", - "@metamask/snaps-controllers": "^14.0.0", "webextension-polyfill": "^0.10.0 || ^0.11.0 || ^0.12.0" }, "engines": { diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts index 2e4547f2ac5..d92b0701e8f 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts @@ -1,10 +1,9 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import AuthenticationController from './AuthenticationController'; diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index 4d64f7b4d1f..37aefcc5d47 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -1,8 +1,8 @@ -import { - BaseController, - type ControllerGetStateAction, - type ControllerStateChangeEvent, - type StateMetadata, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, } from '@metamask/base-controller'; import type { KeyringControllerGetStateAction, diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts index 4f6491d65f0..563101108e0 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts @@ -6,22 +6,22 @@ import type { AddressBookControllerSetAction, AddressBookControllerDeleteAction, } from '@metamask/address-book-controller'; -import { - BaseController, - type ControllerGetStateAction, - type ControllerStateChangeEvent, - type StateMetadata, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, } from '@metamask/base-controller'; import type { TraceCallback, TraceContext, TraceRequest, } from '@metamask/controller-utils'; -import { - KeyringTypes, - type KeyringControllerGetStateAction, - type KeyringControllerLockEvent, - type KeyringControllerUnlockEvent, +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { + KeyringControllerGetStateAction, + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { HandleSnapRequest } from '@metamask/snaps-controllers'; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts index 1856d732b20..2ec6930e250 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts @@ -1,10 +1,9 @@ -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MockAnyNamespace, - type MessengerActions, - type MessengerEvents, - type NotNamespacedBy, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, + NotNamespacedBy, } from '@metamask/messenger'; import type { diff --git a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockServices.ts b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockServices.ts index fdd0571ad96..dddac59d2b6 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockServices.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockServices.ts @@ -1,9 +1,9 @@ import nock from 'nock'; -import { - USER_STORAGE_FEATURE_NAMES, - type UserStorageGenericPathWithFeatureAndKey, - type UserStorageGenericPathWithFeatureOnly, +import { USER_STORAGE_FEATURE_NAMES } from '../../../shared/storage-schema'; +import type { + UserStorageGenericPathWithFeatureAndKey, + UserStorageGenericPathWithFeatureOnly, } from '../../../shared/storage-schema'; import { getMockUserStorageGetResponse, diff --git a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/controller-integration.ts b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/controller-integration.ts index cbc0ebd850e..45b7c1f0748 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/controller-integration.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/controller-integration.ts @@ -3,10 +3,10 @@ import type { AddressBookEntry } from '@metamask/address-book-controller'; import { canPerformContactSyncing } from './sync-utils'; import type { ContactSyncingOptions } from './types'; import type { UserStorageContactEntry } from './types'; +import type { SyncAddressBookEntry } from './utils'; import { mapAddressBookEntryToUserStorageEntry, mapUserStorageEntryToAddressBookEntry, - type SyncAddressBookEntry, } from './utils'; import { isContactBridgedFromAccounts } from './utils'; import { USER_STORAGE_FEATURE_NAMES } from '../../../shared/storage-schema'; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/utils.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/utils.test.ts index ae955f3af4d..c0f2572337e 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/utils.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/utils.test.ts @@ -5,8 +5,8 @@ import type { UserStorageContactEntry } from './types'; import { mapAddressBookEntryToUserStorageEntry, mapUserStorageEntryToAddressBookEntry, - type SyncAddressBookEntry, } from './utils'; +import type { SyncAddressBookEntry } from './utils'; describe('user-storage/contact-syncing/utils', () => { // Use checksum address format for consistent testing diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts index 8fe8b97e4c0..550e20c84e9 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts @@ -1,10 +1,6 @@ import { SRPJwtBearerAuth } from './flow-srp'; -import { - AuthType, - type AuthConfig, - type LoginResponse, - type UserProfile, -} from './types'; +import { AuthType } from './types'; +import type { AuthConfig, LoginResponse, UserProfile } from './types'; import * as timeUtils from './utils/time'; import { Env, Platform } from '../../shared/env'; import { RateLimitedError } from '../errors'; diff --git a/packages/profile-sync-controller/src/sdk/user-storage.test.ts b/packages/profile-sync-controller/src/sdk/user-storage.test.ts index 82775648704..7d73410674f 100644 --- a/packages/profile-sync-controller/src/sdk/user-storage.test.ts +++ b/packages/profile-sync-controller/src/sdk/user-storage.test.ts @@ -10,7 +10,7 @@ import { handleMockUserStorageDelete, handleMockUserStorageBatchDelete, } from './__fixtures__/userstorage'; -import { type IBaseAuth } from './authentication-jwt-bearer/types'; +import type { IBaseAuth } from './authentication-jwt-bearer/types'; import { NotFoundError, UserStorageError } from './errors'; import { MOCK_NOTIFICATIONS_DATA, diff --git a/packages/profile-sync-controller/src/sdk/utils/messaging-signing-snap-requests.test.ts b/packages/profile-sync-controller/src/sdk/utils/messaging-signing-snap-requests.test.ts index 3a7c188d2fd..48e34a81280 100644 --- a/packages/profile-sync-controller/src/sdk/utils/messaging-signing-snap-requests.test.ts +++ b/packages/profile-sync-controller/src/sdk/utils/messaging-signing-snap-requests.test.ts @@ -6,10 +6,8 @@ import { getSnaps, isSnapConnected, } from './messaging-signing-snap-requests'; -import { - arrangeMockProvider, - type MockVariable, -} from '../__fixtures__/test-utils'; +import { arrangeMockProvider } from '../__fixtures__/test-utils'; +import type { MockVariable } from '../__fixtures__/test-utils'; /** * Most of these utilities are wrappers around making wallet requests, diff --git a/packages/rate-limit-controller/src/RateLimitController.test.ts b/packages/rate-limit-controller/src/RateLimitController.test.ts index efa82a9c3a6..ec5f8139981 100644 --- a/packages/rate-limit-controller/src/RateLimitController.test.ts +++ b/packages/rate-limit-controller/src/RateLimitController.test.ts @@ -1,10 +1,9 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { RateLimitMessenger } from './RateLimitController'; diff --git a/packages/rate-limit-controller/src/RateLimitController.ts b/packages/rate-limit-controller/src/RateLimitController.ts index f41a650ce7c..94800c8f007 100644 --- a/packages/rate-limit-controller/src/RateLimitController.ts +++ b/packages/rate-limit-controller/src/RateLimitController.ts @@ -1,7 +1,7 @@ -import { - BaseController, - type ControllerGetStateAction, - type ControllerStateChangeEvent, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; import type { Messenger, ActionConstraint } from '@metamask/messenger'; import { rpcErrors } from '@metamask/rpc-errors'; diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index bab76de786c..35fd65a9d04 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.0] + +### Added + +- Add version-gated feature flags with multi-version support ([#7277](https://github.com/MetaMask/core/pull/7277)) + - Support for feature flags with multiple version entries: `{ versions: { "13.1.0": {...}, "13.2.0": {...} } }` + - Automatic selection of highest qualifying version based on semantic version comparison + - New utility functions: `isVersionFeatureFlag()`, `getVersionData()`, `isVersionAtLeast()` + - Enhanced type safety with `VersionEntry` and `MultiVersionFeatureFlagValue` types + - Comprehensive validation ensures only properly structured version entries are processed + +### Changed + +- **BREAKING:** Add required `clientVersion` parameter to constructor for version-based filtering (expects semantic version string of client app) ([#7277](https://github.com/MetaMask/core/pull/7277)) + ## [2.0.1] ### Changed @@ -132,7 +147,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of the RemoteFeatureFlagController. ([#4931](https://github.com/MetaMask/core/pull/4931)) - This controller manages the retrieval and caching of remote feature flags. It fetches feature flags from a remote API, caches them, and provides methods to access and manage these flags. The controller ensures that feature flags are refreshed based on a specified interval and handles cases where the controller is disabled or the network is unavailable. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/remote-feature-flag-controller@2.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/remote-feature-flag-controller@3.0.0...HEAD +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/remote-feature-flag-controller@2.0.1...@metamask/remote-feature-flag-controller@3.0.0 [2.0.1]: https://github.com/MetaMask/core/compare/@metamask/remote-feature-flag-controller@2.0.0...@metamask/remote-feature-flag-controller@2.0.1 [2.0.0]: https://github.com/MetaMask/core/compare/@metamask/remote-feature-flag-controller@1.9.1...@metamask/remote-feature-flag-controller@2.0.0 [1.9.1]: https://github.com/MetaMask/core/compare/@metamask/remote-feature-flag-controller@1.9.0...@metamask/remote-feature-flag-controller@1.9.1 diff --git a/packages/remote-feature-flag-controller/package.json b/packages/remote-feature-flag-controller/package.json index f22e5ffc967..e54c41cca0f 100644 --- a/packages/remote-feature-flag-controller/package.json +++ b/packages/remote-feature-flag-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/remote-feature-flag-controller", - "version": "2.0.1", + "version": "3.0.0", "description": "The RemoteFeatureFlagController manages the retrieval and caching of remote feature flags", "keywords": [ "MetaMask", diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts index 7d3c0833ea9..6bf8e5ea2fc 100644 --- a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts @@ -1,4 +1,4 @@ -import type { Json } from '@metamask/utils'; +import type { Json, SemVerVersion } from '@metamask/utils'; // Define accepted values for client, distribution, and environment export enum ClientType { @@ -24,6 +24,11 @@ export enum EnvironmentType { Exp = 'exp', } +/** Type representing a feature flag with multiple version entries */ +export type MultiVersionFeatureFlagValue = { + versions: Record; +}; + /** Type representing the feature flags collection */ export type FeatureFlags = { [key: string]: Json; diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts index 1790814b2f6..c1cfc84c119 100644 --- a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts @@ -1,10 +1,9 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { AbstractClientConfigApiService } from './client-config-api-service/abstract-client-config-api-service'; @@ -47,6 +46,7 @@ const MOCK_FLAGS_WITH_THRESHOLD = { }; const MOCK_METRICS_ID = 'f9e8d7c6-b5a4-4210-9876-543210fedcba'; +const MOCK_BASE_VERSION = '13.10.0'; /** * Creates a controller instance with default parameters for testing @@ -65,6 +65,7 @@ function createController( clientConfigApiService: AbstractClientConfigApiService; disabled: boolean; getMetaMetricsId: () => string; + clientVersion: string; }> = {}, ) { return new RemoteFeatureFlagController({ @@ -74,6 +75,7 @@ function createController( options.clientConfigApiService ?? buildClientConfigApiService(), disabled: options.disabled, getMetaMetricsId: options.getMetaMetricsId ?? (() => MOCK_METRICS_ID), + clientVersion: options.clientVersion ?? MOCK_BASE_VERSION, }); } @@ -107,6 +109,38 @@ describe('RemoteFeatureFlagController', () => { expect(controller.state).toStrictEqual(customState); }); + + it('accepts valid 3-part SemVer clientVersion', () => { + expect(() => + createController({ clientVersion: MOCK_BASE_VERSION }), + ).not.toThrow(); + expect(() => createController({ clientVersion: '1.0.0' })).not.toThrow(); + expect(() => createController({ clientVersion: '14.5.2' })).not.toThrow(); + }); + + it('throws error for invalid clientVersion formats', () => { + expect(() => createController({ clientVersion: '13.10' })).toThrow( + 'Invalid clientVersion: "13.10". Must be a valid 3-part SemVer version string', + ); + + expect(() => createController({ clientVersion: '13' })).toThrow( + 'Invalid clientVersion: "13". Must be a valid 3-part SemVer version string', + ); + + expect(() => createController({ clientVersion: '13.10.0.1' })).toThrow( + 'Invalid clientVersion: "13.10.0.1". Must be a valid 3-part SemVer version string', + ); + + expect(() => + createController({ clientVersion: 'invalid-version' }), + ).toThrow( + 'Invalid clientVersion: "invalid-version". Must be a valid 3-part SemVer version string', + ); + + expect(() => createController({ clientVersion: '' })).toThrow( + 'Invalid clientVersion: "". Must be a valid 3-part SemVer version string', + ); + }); }); describe('updateRemoteFeatureFlags', () => { @@ -339,6 +373,267 @@ describe('RemoteFeatureFlagController', () => { }); }); + describe('Multi-version feature flags', () => { + it('handles single version in versions array', async () => { + const mockApiService = buildClientConfigApiService(); + const mockFlags = { + singleVersionInArray: { + versions: { '13.1.0': { x: '12' } }, + }, + }; + + jest.spyOn(mockApiService, 'fetchRemoteFeatureFlags').mockResolvedValue({ + remoteFeatureFlags: mockFlags, + cacheTimestamp: Date.now(), + }); + + const controller = createController({ + clientConfigApiService: mockApiService, + clientVersion: '13.2.0', + }); + + await controller.updateRemoteFeatureFlags(); + + const { singleVersionInArray } = controller.state.remoteFeatureFlags; + expect(singleVersionInArray).toStrictEqual({ x: '12' }); + }); + + it('selects highest version when app version qualifies for multiple', async () => { + const mockApiService = buildClientConfigApiService(); + const mockFlags = { + multiVersionFeature: { + versions: { + '13.2.0': { x: '13' }, + '13.1.0': { x: '12' }, + '13.0.5': { x: '11' }, + }, + }, + }; + + jest.spyOn(mockApiService, 'fetchRemoteFeatureFlags').mockResolvedValue({ + remoteFeatureFlags: mockFlags, + cacheTimestamp: Date.now(), + }); + + const controller = createController({ + clientConfigApiService: mockApiService, + clientVersion: '13.2.5', + }); + + await controller.updateRemoteFeatureFlags(); + + const { multiVersionFeature } = controller.state.remoteFeatureFlags; + // Should get version 13.2.0 (highest version that 13.2.5 qualifies for) + expect(multiVersionFeature).toStrictEqual({ x: '13' }); + }); + + it('selects appropriate version based on app version', async () => { + const mockApiService = buildClientConfigApiService(); + const mockFlags = { + multiVersionFeature: { + versions: { + '13.2.0': { x: '13' }, + '13.1.0': { x: '12' }, + '13.0.5': { x: '11' }, + }, + }, + regularFeature: true, + }; + + jest.spyOn(mockApiService, 'fetchRemoteFeatureFlags').mockResolvedValue({ + remoteFeatureFlags: mockFlags, + cacheTimestamp: Date.now(), + }); + + const controller = createController({ + clientConfigApiService: mockApiService, + clientVersion: '13.1.5', + }); + + await controller.updateRemoteFeatureFlags(); + + const { multiVersionFeature, regularFeature } = + controller.state.remoteFeatureFlags; + // Should get version 13.1.0 (highest version that 13.1.5 qualifies for) + expect(multiVersionFeature).toStrictEqual({ x: '12' }); + expect(regularFeature).toBe(true); + }); + + it('excludes multi-version feature when no versions qualify', async () => { + const mockApiService = buildClientConfigApiService(); + const mockFlags = { + multiVersionFeature: { + versions: { + '13.2.0': { x: '13' }, + '13.1.0': { x: '12' }, + }, + }, + regularFeature: true, + }; + + jest.spyOn(mockApiService, 'fetchRemoteFeatureFlags').mockResolvedValue({ + remoteFeatureFlags: mockFlags, + cacheTimestamp: Date.now(), + }); + + const controller = createController({ + clientConfigApiService: mockApiService, + clientVersion: '13.0.0', + }); + + await controller.updateRemoteFeatureFlags(); + + const { multiVersionFeature, regularFeature } = + controller.state.remoteFeatureFlags; + // Multi-version feature should be excluded (app version too low) + expect(multiVersionFeature).toBeUndefined(); + // Regular feature should still be included + expect(regularFeature).toBe(true); + }); + + it('handles mixed regular and multi-version flags', async () => { + const mockApiService = buildClientConfigApiService(); + const mockFlags = { + multiVersionFeature: { + versions: { + '13.2.0': { x: '13' }, + '13.0.0': { x: '11' }, + }, + }, + regularFeature: { enabled: true }, + simpleFeature: true, + }; + + jest.spyOn(mockApiService, 'fetchRemoteFeatureFlags').mockResolvedValue({ + remoteFeatureFlags: mockFlags, + cacheTimestamp: Date.now(), + }); + + const controller = createController({ + clientConfigApiService: mockApiService, + clientVersion: '13.1.5', + }); + + await controller.updateRemoteFeatureFlags(); + + const { multiVersionFeature, regularFeature, simpleFeature } = + controller.state.remoteFeatureFlags; + expect(multiVersionFeature).toStrictEqual({ x: '11' }); + expect(regularFeature).toStrictEqual({ enabled: true }); + expect(simpleFeature).toBe(true); + }); + + it('excludes feature flags with invalid version structure and processes valid ones', async () => { + const mockApiService = buildClientConfigApiService(); + const mockFlags = { + invalidVersionFlag: { + versions: 'not-an-object', // Invalid: versions should be an object + }, + validVersionFlag: { + versions: { + '13.1.0': { x: '12' }, + '13.2.0': { x: '13' }, + }, + }, + validFlag: { + versions: { '13.1.0': { x: '14' } }, + }, + regularFeature: true, + }; + + jest.spyOn(mockApiService, 'fetchRemoteFeatureFlags').mockResolvedValue({ + remoteFeatureFlags: mockFlags, + cacheTimestamp: Date.now(), + }); + + const controller = createController({ + clientConfigApiService: mockApiService, + clientVersion: '13.2.0', + }); + + await controller.updateRemoteFeatureFlags(); + + const { + invalidVersionFlag, + validVersionFlag, + validFlag, + regularFeature, + } = controller.state.remoteFeatureFlags; + expect(invalidVersionFlag).toStrictEqual({ + versions: 'not-an-object', // Should remain unprocessed + }); + // Valid version flag should be processed and return highest eligible version + expect(validVersionFlag).toStrictEqual({ x: '13' }); + // Valid version flag should be processed + expect(validFlag).toStrictEqual({ x: '14' }); + // Regular flag should remain unchanged + expect(regularFeature).toBe(true); + }); + + it('combines multi-version flags with A/B testing (threshold-based scoped values)', async () => { + const mockApiService = buildClientConfigApiService(); + const mockFlags = { + multiVersionABFlag: { + versions: { + '13.1.0': [ + { + name: 'groupA', + scope: { type: 'threshold', value: 0.3 }, + value: { feature: 'A', enabled: true }, + }, + { + name: 'groupB', + scope: { type: 'threshold', value: 0.7 }, + value: { feature: 'B', enabled: false }, + }, + { + name: 'groupC', + scope: { type: 'threshold', value: 1.0 }, + value: { feature: 'C', enabled: true }, + }, + ], + '13.2.0': [ + { + name: 'newGroupA', + scope: { type: 'threshold', value: 0.5 }, + value: { feature: 'NewA', enabled: false }, + }, + { + name: 'newGroupB', + scope: { type: 'threshold', value: 1.0 }, + value: { feature: 'NewB', enabled: true }, + }, + ], + }, + }, + regularFlag: true, + }; + + jest.spyOn(mockApiService, 'fetchRemoteFeatureFlags').mockResolvedValue({ + remoteFeatureFlags: mockFlags, + cacheTimestamp: Date.now(), + }); + + const controller = createController({ + clientConfigApiService: mockApiService, + clientVersion: '13.1.5', // Qualifies for 13.1.0 version but not 13.2.0 + getMetaMetricsId: () => MOCK_METRICS_ID, // This generates threshold > 0.7 + }); + + await controller.updateRemoteFeatureFlags(); + + const { multiVersionABFlag, regularFlag } = + controller.state.remoteFeatureFlags; + // Should select 13.1.0 version and then apply A/B testing to that array + // With MOCK_METRICS_ID threshold, should select groupC (threshold 1.0) + expect(multiVersionABFlag).toStrictEqual({ + name: 'groupC', + value: { feature: 'C', enabled: true }, + }); + expect(regularFlag).toBe(true); + }); + }); + describe('getDefaultRemoteFeatureFlagControllerState', () => { it('should return default state', () => { expect(getDefaultRemoteFeatureFlagControllerState()).toStrictEqual({ diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts index 0ff29495912..09fa8c1c682 100644 --- a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts @@ -1,9 +1,11 @@ -import { - BaseController, - type ControllerGetStateAction, - type ControllerStateChangeEvent, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; +import { isValidSemVerVersion } from '@metamask/utils'; +import type { Json, SemVerVersion } from '@metamask/utils'; import type { AbstractClientConfigApiService } from './client-config-api-service/abstract-client-config-api-service'; import type { @@ -15,6 +17,7 @@ import { generateDeterministicRandomNumber, isFeatureFlagWithScopeValue, } from './utils/user-segmentation-utils'; +import { isVersionFeatureFlag, getVersionData } from './utils/version'; // === GENERAL === @@ -111,6 +114,8 @@ export class RemoteFeatureFlagController extends BaseController< readonly #getMetaMetricsId: () => string; + readonly #clientVersion: SemVerVersion; + /** * Constructs a new RemoteFeatureFlagController instance. * @@ -121,6 +126,7 @@ export class RemoteFeatureFlagController extends BaseController< * @param options.fetchInterval - The interval in milliseconds before cached flags expire. Defaults to 1 day. * @param options.disabled - Determines if the controller should be disabled initially. Defaults to false. * @param options.getMetaMetricsId - Returns metaMetricsId. + * @param options.clientVersion - The current client version for version-based feature flag filtering. Must be a valid 3-part SemVer version string. */ constructor({ messenger, @@ -129,6 +135,7 @@ export class RemoteFeatureFlagController extends BaseController< fetchInterval = DEFAULT_CACHE_DURATION, disabled = false, getMetaMetricsId, + clientVersion, }: { messenger: RemoteFeatureFlagControllerMessenger; state?: Partial; @@ -136,7 +143,14 @@ export class RemoteFeatureFlagController extends BaseController< getMetaMetricsId: () => string; fetchInterval?: number; disabled?: boolean; + clientVersion: string; }) { + if (!isValidSemVerVersion(clientVersion)) { + throw new Error( + `Invalid clientVersion: "${clientVersion}". Must be a valid 3-part SemVer version string`, + ); + } + super({ name: controllerName, metadata: remoteFeatureFlagControllerMetadata, @@ -151,6 +165,7 @@ export class RemoteFeatureFlagController extends BaseController< this.#disabled = disabled; this.#clientConfigApiService = clientConfigApiService; this.#getMetaMetricsId = getMetaMetricsId; + this.#clientVersion = clientVersion; } /** @@ -208,6 +223,20 @@ export class RemoteFeatureFlagController extends BaseController< }); } + /** + * Processes a version-based feature flag to get the appropriate value for the current client version. + * + * @param flagValue - The feature flag value to process + * @returns The processed value, or null if no version qualifies (skip this flag) + */ + #processVersionBasedFlag(flagValue: Json): Json | null { + if (!isVersionFeatureFlag(flagValue)) { + return flagValue; + } + + return getVersionData(flagValue, this.#clientVersion); + } + async #processRemoteFeatureFlags( remoteFeatureFlags: FeatureFlags, ): Promise { @@ -219,10 +248,15 @@ export class RemoteFeatureFlagController extends BaseController< remoteFeatureFlagName, remoteFeatureFlagValue, ] of Object.entries(remoteFeatureFlags)) { - let processedValue = remoteFeatureFlagValue; + let processedValue = this.#processVersionBasedFlag( + remoteFeatureFlagValue, + ); + if (processedValue === null) { + continue; + } - if (Array.isArray(remoteFeatureFlagValue) && thresholdValue) { - const selectedGroup = remoteFeatureFlagValue.find( + if (Array.isArray(processedValue) && thresholdValue) { + const selectedGroup = processedValue.find( (featureFlag): featureFlag is FeatureFlagScopeValue => { if (!isFeatureFlagWithScopeValue(featureFlag)) { return false; diff --git a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts index 43bcb8cecca..757a8b7fd2a 100644 --- a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts +++ b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts @@ -11,8 +11,8 @@ const MOCK_METRICS_IDS = { '0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420', MOBILE_MIN: '00000000-0000-4000-8000-000000000000', MOBILE_MAX: 'ffffffff-ffff-4fff-bfff-ffffffffffff', - EXTENSION_MIN: `0x${'0'.repeat(64) as string}`, - EXTENSION_MAX: `0x${'f'.repeat(64) as string}`, + EXTENSION_MIN: `0x${'0'.repeat(64)}`, + EXTENSION_MAX: `0x${'f'.repeat(64)}`, UUID_V3: '00000000-0000-3000-8000-000000000000', INVALID_HEX_NO_PREFIX: '86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420', diff --git a/packages/remote-feature-flag-controller/src/utils/version.test.ts b/packages/remote-feature-flag-controller/src/utils/version.test.ts new file mode 100644 index 00000000000..e7133d34e08 --- /dev/null +++ b/packages/remote-feature-flag-controller/src/utils/version.test.ts @@ -0,0 +1,169 @@ +import type { SemVerVersion } from '@metamask/utils'; + +import { isVersionFeatureFlag, getVersionData } from './version'; + +describe('isVersionFeatureFlag', () => { + it('returns true for valid multi-version feature flag', () => { + const multiVersionFlag = { + versions: { + '13.1.0': { x: '12' }, + '13.2.0': { x: '13' }, + }, + }; + expect(isVersionFeatureFlag(multiVersionFlag)).toBe(true); + }); + + it('returns true for empty versions object', () => { + const flag = { versions: {} }; + expect(isVersionFeatureFlag(flag)).toBe(true); // Still valid structure + }); + + it('returns false for null', () => { + expect(isVersionFeatureFlag(null)).toBe(false); + }); + + it('returns false for primitives', () => { + expect(isVersionFeatureFlag(true)).toBe(false); + expect(isVersionFeatureFlag('string')).toBe(false); + }); + + it('returns false when versions is not an object', () => { + const flag = { versions: 'not-an-object' }; + expect(isVersionFeatureFlag(flag)).toBe(false); + }); + + it('returns false when missing versions property', () => { + const flag = { otherProperty: [] }; + expect(isVersionFeatureFlag(flag)).toBe(false); + }); + + it('returns false when versions is an array', () => { + const flagWithArray = { + versions: [ + { fromVersion: '13.1.0', data: { x: '12' } }, + { fromVersion: '13.2.0', data: true }, + ], + }; + expect(isVersionFeatureFlag(flagWithArray)).toBe(false); + }); + + it('returns false when versions is null', () => { + const flagWithNull = { + versions: null, + }; + expect(isVersionFeatureFlag(flagWithNull)).toBe(false); + }); + + it('returns false when versions is a primitive', () => { + const flagWithString = { + versions: 'not-an-object', + }; + expect(isVersionFeatureFlag(flagWithString)).toBe(false); + + const flagWithNumber = { + versions: 123, + }; + expect(isVersionFeatureFlag(flagWithNumber)).toBe(false); + }); + + it('returns true when versions is a valid object with valid SemVer keys', () => { + const validFlag = { + versions: { + '13.1.0': { x: '12' }, + '13.2.0': true, + '13.0.5': 'string-value', + }, + }; + expect(isVersionFeatureFlag(validFlag)).toBe(true); + }); + + it('returns false when version keys are not valid SemVer', () => { + const flagWithInvalidVersions = { + versions: { + '13.10': { x: '12' }, // Not valid SemVer (missing patch) + '13.2.0': { x: '13' }, + }, + }; + expect(isVersionFeatureFlag(flagWithInvalidVersions)).toBe(false); + }); + + it('returns false when version keys contain single component versions', () => { + const flagWithSingleComponent = { + versions: { + '13': { x: '12' }, // Not valid SemVer + '14.0.0': { x: '13' }, + }, + }; + expect(isVersionFeatureFlag(flagWithSingleComponent)).toBe(false); + }); + + it('returns false when version keys contain 4+ component versions', () => { + const flagWithFourComponents = { + versions: { + '13.10.0.1': { x: '12' }, // Not valid SemVer + '13.2.0': { x: '13' }, + }, + }; + expect(isVersionFeatureFlag(flagWithFourComponents)).toBe(false); + }); + + it('returns false when version keys are invalid strings', () => { + const flagWithInvalidStrings = { + versions: { + 'invalid-version': { x: '12' }, + '13.2.0': { x: '13' }, + }, + }; + expect(isVersionFeatureFlag(flagWithInvalidStrings)).toBe(false); + }); +}); + +describe('getVersionData', () => { + const multiVersionFlag = { + versions: { + // The object keys can be in any order since we sort them + '13.0.5': { x: '11' }, + '13.2.0': { x: '13' }, + '13.1.0': { x: '12' }, + }, + }; + + it('returns highest eligible version when multiple versions qualify', () => { + const result = getVersionData(multiVersionFlag, '13.2.5' as SemVerVersion); + expect(result).toStrictEqual({ x: '13' }); + }); + + it('returns appropriate version when only some versions qualify', () => { + const result = getVersionData(multiVersionFlag, '13.1.5' as SemVerVersion); + expect(result).toStrictEqual({ x: '12' }); + }); + + it('returns lowest version when app version is very high', () => { + const result = getVersionData(multiVersionFlag, '14.0.0' as SemVerVersion); + expect(result).toStrictEqual({ x: '13' }); + }); + + it('returns null when no versions qualify', () => { + const result = getVersionData(multiVersionFlag, '13.0.0' as SemVerVersion); + expect(result).toBeNull(); + }); + + it('returns exact match when app version equals fromVersion', () => { + const result = getVersionData(multiVersionFlag, '13.1.0' as SemVerVersion); + expect(result).toStrictEqual({ x: '12' }); + }); + + it('handles single version in object', () => { + const singleVersionFlag = { + versions: { '13.1.0': { x: '12' } }, + }; + const result = getVersionData(singleVersionFlag, '13.1.5' as SemVerVersion); + expect(result).toStrictEqual({ x: '12' }); + }); + + it('returns null for empty versions object', () => { + const emptyVersionFlag = { versions: {} }; + const result = getVersionData(emptyVersionFlag, '13.1.0' as SemVerVersion); + expect(result).toBeNull(); + }); +}); diff --git a/packages/remote-feature-flag-controller/src/utils/version.ts b/packages/remote-feature-flag-controller/src/utils/version.ts new file mode 100644 index 00000000000..94a5c0dad74 --- /dev/null +++ b/packages/remote-feature-flag-controller/src/utils/version.ts @@ -0,0 +1,103 @@ +import { gtVersion, isValidSemVerVersion } from '@metamask/utils'; +import type { Json, SemVerVersion } from '@metamask/utils'; + +import type { MultiVersionFeatureFlagValue } from '../remote-feature-flag-controller-types'; + +/** + * Constants for MultiVersionFeatureFlagValue property names + * to ensure consistency across validation, type checking, and other usage. + */ +export const MULTI_VERSION_FLAG_KEYS = { + VERSIONS: 'versions', +} as const; + +/** + * Checks if a feature flag value is a multi-version gated flag (contains versions object with valid SemVer keys). + * + * @param value - The feature flag value to check + * @returns true if the value is a multi-version feature flag with valid SemVer version keys, false otherwise + */ +export function isVersionFeatureFlag( + value: Json, +): value is MultiVersionFeatureFlagValue { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + !(MULTI_VERSION_FLAG_KEYS.VERSIONS in value) + ) { + return false; + } + + const versions = value[MULTI_VERSION_FLAG_KEYS.VERSIONS]; + + if ( + typeof versions !== 'object' || + versions === null || + Array.isArray(versions) + ) { + return false; + } + + // Validate that all version keys are valid SemVer versions + const versionKeys = Object.keys(versions); + return versionKeys.every((versionKey) => isValidSemVerVersion(versionKey)); +} + +/** + * Selects the appropriate version value from a multi-version feature flag based on current app version. + * Returns the value from the highest version that the app version meets or exceeds. + * + * @param multiVersionFlag - The multi-version feature flag + * @param currentAppVersion - The current application version + * @returns The selected version value, or null if no version requirements are met + */ +export function getVersionData( + multiVersionFlag: MultiVersionFeatureFlagValue, + currentAppVersion: SemVerVersion, +): Json | null { + const sortedVersions = getObjectEntries(multiVersionFlag.versions).sort( + ([versionA], [versionB]) => { + return isVersionAtLeast(versionA, versionB) ? -1 : 1; + }, + ); + + for (const [version, data] of sortedVersions) { + if (isVersionAtLeast(currentAppVersion, version)) { + return data; + } + } + + return null; +} + +/** + * Compares two semantic version strings. + * Both versions are expected to be valid SemVer format (e.g., "13.9.5"). + * + * @param currentVersion - The current version (e.g., "13.9.5") + * @param requiredVersion - The required minimum version (e.g., "13.10.0") + * @returns true if currentVersion >= requiredVersion, false otherwise + */ +function isVersionAtLeast( + currentVersion: SemVerVersion, + requiredVersion: SemVerVersion, +): boolean { + return ( + currentVersion === requiredVersion || + gtVersion(currentVersion, requiredVersion) + ); +} + +/** + * A utility function that calls `Object.entries` while preserving the index type. + * + * @param input - The object to get the entries of. + * @returns - The object entries. + */ +function getObjectEntries>( + input: Input, +): [keyof Input, Input[keyof Input]][] { + // Use cast to preserve index type. Object.entries always widens to string. + return Object.entries(input) as [keyof Input, Input[keyof Input]][]; +} diff --git a/packages/sample-controllers/CHANGELOG.md b/packages/sample-controllers/CHANGELOG.md index 13c0760512c..c4222c3cea7 100644 --- a/packages/sample-controllers/CHANGELOG.md +++ b/packages/sample-controllers/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7258](https://github.com/MetaMask/core/pull/7258)) + - The dependencies moved are: + - `@metamask/network-controller` (^27.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [4.0.0] ### Changed diff --git a/packages/sample-controllers/package.json b/packages/sample-controllers/package.json index 9ed6cbb527c..c78f0b27d42 100644 --- a/packages/sample-controllers/package.json +++ b/packages/sample-controllers/package.json @@ -50,12 +50,12 @@ "dependencies": { "@metamask/base-controller": "^9.0.0", "@metamask/messenger": "^0.3.0", + "@metamask/network-controller": "^27.0.0", "@metamask/utils": "^11.8.1" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@metamask/controller-utils": "^11.16.0", - "@metamask/network-controller": "^26.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -67,9 +67,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/network-controller": "^26.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/sample-controllers/src/sample-gas-prices-controller.test.ts b/packages/sample-controllers/src/sample-gas-prices-controller.test.ts index 03c1ae19840..9dca9f7a5d4 100644 --- a/packages/sample-controllers/src/sample-gas-prices-controller.test.ts +++ b/packages/sample-controllers/src/sample-gas-prices-controller.test.ts @@ -1,10 +1,9 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MockAnyNamespace, - type MessengerActions, - type MessengerEvents, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, } from '@metamask/messenger'; import { SampleGasPricesController } from '@metamask/sample-controllers'; import type { SampleGasPricesControllerMessenger } from '@metamask/sample-controllers'; diff --git a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts index 1796ea2bd6e..42d71884c83 100644 --- a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts +++ b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts @@ -1,10 +1,9 @@ import { HttpError } from '@metamask/controller-utils'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MockAnyNamespace, - type MessengerActions, - type MessengerEvents, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, } from '@metamask/messenger'; import nock from 'nock'; import { useFakeTimers } from 'sinon'; diff --git a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts index 15611143ec8..817a3704f3b 100644 --- a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts +++ b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts @@ -8,7 +8,8 @@ import { HttpError, } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; -import { hasProperty, isPlainObject, type Hex } from '@metamask/utils'; +import { hasProperty, isPlainObject } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import type { SampleGasPricesServiceMethodActions } from './sample-gas-prices-service-method-action-types'; @@ -195,7 +196,6 @@ export class SampleGasPricesService { return this.#policy.onBreak(listener); } - /* eslint-disable jsdoc/check-indentation */ /** * Registers a handler that will be called under one of two circumstances: * @@ -213,7 +213,6 @@ export class SampleGasPricesService { * @returns An object that can be used to unregister the handler. See * {@link CockatielEvent}. */ - /* eslint-enable jsdoc/check-indentation */ onDegraded(listener: Parameters[0]) { return this.#policy.onDegraded(listener); } diff --git a/packages/sample-controllers/src/sample-petnames-controller.test.ts b/packages/sample-controllers/src/sample-petnames-controller.test.ts index 45bfd0d1d60..96e45b803da 100644 --- a/packages/sample-controllers/src/sample-petnames-controller.test.ts +++ b/packages/sample-controllers/src/sample-petnames-controller.test.ts @@ -1,10 +1,9 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MockAnyNamespace, - type MessengerActions, - type MessengerEvents, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, } from '@metamask/messenger'; import type { SamplePetnamesControllerMessenger } from './sample-petnames-controller'; diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 171a0c9999f..7b5a2124084 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.1.0] + +### Added + +- Added new public method,`preloadToprfNodeDetails` to pre-load the TROPF Node details. ([#7300](https://github.com/MetaMask/core/pull/7300)) + +### Changed + +- Bump `@metamask/toprf-secure-backup` to `0.11.0`. ([#7300](https://github.com/MetaMask/core/pull/7300)) +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/keyring-controller` (^25.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [7.0.0] ### Changed @@ -238,7 +254,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `checkIsPasswordOutdated`: Check if the password is current device is outdated, i.e. user changed password in another device. - `clearState`: Reset the state of the controller to the defaults. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@7.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@7.1.0...HEAD +[7.1.0]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@7.0.0...@metamask/seedless-onboarding-controller@7.1.0 [7.0.0]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@6.1.0...@metamask/seedless-onboarding-controller@7.0.0 [6.1.0]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@6.0.0...@metamask/seedless-onboarding-controller@6.1.0 [6.0.0]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@5.0.0...@metamask/seedless-onboarding-controller@6.0.0 diff --git a/packages/seedless-onboarding-controller/package.json b/packages/seedless-onboarding-controller/package.json index be83fcfebba..c2086a1db29 100644 --- a/packages/seedless-onboarding-controller/package.json +++ b/packages/seedless-onboarding-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/seedless-onboarding-controller", - "version": "7.0.0", + "version": "7.1.0", "description": "Backup and rehydrate SRP(s) using social login and password", "keywords": [ "MetaMask", @@ -51,8 +51,9 @@ "@metamask/auth-network-utils": "^0.3.0", "@metamask/base-controller": "^9.0.0", "@metamask/browser-passworder": "^6.0.0", + "@metamask/keyring-controller": "^25.0.0", "@metamask/messenger": "^0.3.0", - "@metamask/toprf-secure-backup": "^0.10.0", + "@metamask/toprf-secure-backup": "^0.11.0", "@metamask/utils": "^11.8.1", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.2", @@ -63,7 +64,6 @@ "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^25.0.0", "@ts-bridge/cli": "^0.6.4", "@types/elliptic": "^6", "@types/jest": "^27.4.1", @@ -77,9 +77,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/keyring-controller": "^25.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 12b053a2953..7362186ddd4 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -15,15 +15,14 @@ import { generateSalt as generateSaltBrowserPassworder, keyFromPassword as keyFromPasswordBrowserPassworder, } from '@metamask/browser-passworder'; -import { - TOPRFError, - type FetchAuthPubKeyResult, - type SEC1EncodedPublicKey, - type ChangeEncryptionKeyResult, - type KeyPair, - type RecoverEncryptionKeyResult, - type ToprfSecureBackup, - TOPRFErrorCode, +import { TOPRFError, TOPRFErrorCode } from '@metamask/toprf-secure-backup'; +import type { + FetchAuthPubKeyResult, + SEC1EncodedPublicKey, + ChangeEncryptionKeyResult, + KeyPair, + RecoverEncryptionKeyResult, + ToprfSecureBackup, } from '@metamask/toprf-secure-backup'; import { base64ToBytes, @@ -808,6 +807,33 @@ describe('SeedlessOnboardingController', () => { }); }); + describe('fetchNodeDetails', () => { + it('should be able to fetch the node details', async () => { + await withController(async ({ controller, toprfClient }) => { + const getNodeDetailsSpy = jest + .spyOn(toprfClient, 'getNodeDetails') + .mockResolvedValue({ + // @ts-expect-error - test node details + nodeDetails: [], + }); + + await controller.preloadToprfNodeDetails(); + + expect(getNodeDetailsSpy).toHaveBeenCalled(); + }); + }); + + it('should not throw an error if the node details fetch fails', async () => { + await withController(async ({ controller, toprfClient }) => { + const getNodeDetailsSpy = jest + .spyOn(toprfClient, 'getNodeDetails') + .mockRejectedValueOnce(new Error('Failed to fetch node details')); + await controller.preloadToprfNodeDetails(); + expect(getNodeDetailsSpy).toHaveBeenCalled(); + }); + }); + }); + describe('authenticate', () => { it('should be able to register a new user', async () => { await withController(async ({ controller, toprfClient }) => { diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 72d58c7ed2a..725713e6ae8 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1,5 +1,6 @@ import { keccak256AndHexify } from '@metamask/auth-network-utils'; -import { BaseController, type StateMetadata } from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { StateMetadata } from '@metamask/base-controller'; import type * as encryptionUtils from '@metamask/browser-passworder'; import type { KeyPair, @@ -345,6 +346,18 @@ export class SeedlessOnboardingController< return { metadataAccessToken }; } + /** + * Gets the node details for the TOPRF operations. + * This function can be called to get the node endpoints, indexes and pubkeys and cache them locally. + */ + async preloadToprfNodeDetails() { + try { + await this.toprfClient.getNodeDetails(); + } catch { + log('Failed to fetch node details'); + } + } + /** * Authenticate OAuth user using the seedless onboarding flow * and determine if the user is already registered or not. @@ -2140,7 +2153,7 @@ function assertIsValidPassword(password: unknown): asserts password is string { throw new Error(SeedlessOnboardingControllerErrorMessage.WrongPasswordType); } - if (!password || !password.length) { + if (!password?.length) { throw new Error( SeedlessOnboardingControllerErrorMessage.InvalidEmptyPassword, ); diff --git a/packages/seedless-onboarding-controller/src/errors.ts b/packages/seedless-onboarding-controller/src/errors.ts index 14284b5888a..7f5c0224de8 100644 --- a/packages/seedless-onboarding-controller/src/errors.ts +++ b/packages/seedless-onboarding-controller/src/errors.ts @@ -1,8 +1,5 @@ -import { - type RateLimitErrorData, - TOPRFError, - TOPRFErrorCode, -} from '@metamask/toprf-secure-backup'; +import { TOPRFError, TOPRFErrorCode } from '@metamask/toprf-secure-backup'; +import type { RateLimitErrorData } from '@metamask/toprf-secure-backup'; import { SeedlessOnboardingControllerErrorMessage } from './constants'; import type { RecoveryErrorData } from './types'; diff --git a/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts b/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts index 07e62860135..c10a9dcf8e3 100644 --- a/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts +++ b/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts @@ -2,16 +2,15 @@ import type { KeyringControllerLockEvent, KeyringControllerUnlockEvent, } from '@metamask/keyring-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MockAnyNamespace, - type MessengerActions, - type MessengerEvents, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, } from '@metamask/messenger'; import { controllerName } from '../../src/constants'; -import { type SeedlessOnboardingControllerMessenger } from '../../src/types'; +import type { SeedlessOnboardingControllerMessenger } from '../../src/types'; export type AllSeedlessOnboardingControllerActions = MessengerActions; diff --git a/packages/selected-network-controller/CHANGELOG.md b/packages/selected-network-controller/CHANGELOG.md index b9f7b1f6a98..ba8cd319bcc 100644 --- a/packages/selected-network-controller/CHANGELOG.md +++ b/packages/selected-network-controller/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7258](https://github.com/MetaMask/core/pull/7258)) + - The dependencies moved are: + - `@metamask/network-controller` (^27.0.0) + - `@metamask/permission-controller` (^12.1.1) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [26.0.0] ### Changed diff --git a/packages/selected-network-controller/package.json b/packages/selected-network-controller/package.json index 7f227c5de4c..309c236fc45 100644 --- a/packages/selected-network-controller/package.json +++ b/packages/selected-network-controller/package.json @@ -51,13 +51,13 @@ "@metamask/base-controller": "^9.0.0", "@metamask/json-rpc-engine": "^10.2.0", "@metamask/messenger": "^0.3.0", + "@metamask/network-controller": "^27.0.0", + "@metamask/permission-controller": "^12.1.1", "@metamask/swappable-obj-proxy": "^2.3.0", "@metamask/utils": "^11.8.1" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^26.0.0", - "@metamask/permission-controller": "^12.1.1", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -71,10 +71,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/network-controller": "^26.0.0", - "@metamask/permission-controller": "^12.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/selected-network-controller/src/SelectedNetworkController.ts b/packages/selected-network-controller/src/SelectedNetworkController.ts index 0826df76929..1dd7aef0b5b 100644 --- a/packages/selected-network-controller/src/SelectedNetworkController.ts +++ b/packages/selected-network-controller/src/SelectedNetworkController.ts @@ -1,7 +1,7 @@ -import { - BaseController, - type ControllerGetStateAction, - type ControllerStateChangeEvent, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import type { @@ -199,16 +199,12 @@ export class SelectedNetworkController extends BaseController< if (patch) { const networkClientIdToChainId = Object.values( networkConfigurationsByChainId, - ).reduce( - (acc, network) => { - network.rpcEndpoints.forEach( - ({ networkClientId }) => - (acc[networkClientId] = network.chainId), - ); - return acc; - }, - {} as Record, - ); + ).reduce>((acc, network) => { + network.rpcEndpoints.forEach( + ({ networkClientId }) => (acc[networkClientId] = network.chainId), + ); + return acc; + }, {}); Object.entries(this.state.domains).forEach( ([domain, networkClientIdForDomain]) => { diff --git a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts index 5c97f46b815..ee89faeae9a 100644 --- a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts +++ b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts @@ -1,18 +1,19 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { - type ProviderProxy, - type BlockTrackerProxy, - type NetworkState, getDefaultNetworkControllerState, RpcEndpointType, } from '@metamask/network-controller'; +import type { + ProviderProxy, + BlockTrackerProxy, + NetworkState, +} from '@metamask/network-controller'; import { createEventEmitterProxy, createSwappableProxy, diff --git a/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts b/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts index 34893ed56da..c57deb36fce 100644 --- a/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts +++ b/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts @@ -1,10 +1,9 @@ import { JsonRpcEngine } from '@metamask/json-rpc-engine'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import type { JsonRpcResponse } from '@metamask/utils'; diff --git a/packages/shield-controller/CHANGELOG.md b/packages/shield-controller/CHANGELOG.md index af8a58d7a21..005549a0c70 100644 --- a/packages/shield-controller/CHANGELOG.md +++ b/packages/shield-controller/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/signature-controller` from `^37.0.0` to `^38.0.0` ([#7330](https://github.com/MetaMask/core/pull/7330)) +- Bump `@metamask/transaction-controller` from `^62.3.0` to `^62.5.0` ([#7257](https://github.com/MetaMask/core/pull/7257), [#7289](https://github.com/MetaMask/core/pull/7289), [#7325](https://github.com/MetaMask/core/pull/7325)) + +## [3.1.0] + +### Added + +- Added `AuthorizationList` in transaction init and log requests for 7702 transactions. ([#7246](https://github.com/MetaMask/core/pull/7246)) + +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7220](https://github.com/MetaMask/core/pull/7220), [#7236](https://github.com/MetaMask/core/pull/7236)) + - The dependencies moved are: + - `@metamask/signature-controller` (^37.0.0) + - `@metamask/transaction-controller` (^62.3.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [3.0.0] ### Changed @@ -146,7 +169,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of the shield-controller package ([#6137](https://github.com/MetaMask/core/pull/6137) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@3.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@4.0.0...HEAD +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@3.1.0...@metamask/shield-controller@4.0.0 +[3.1.0]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@3.0.0...@metamask/shield-controller@3.1.0 [3.0.0]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@2.1.1...@metamask/shield-controller@3.0.0 [2.1.1]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@2.1.0...@metamask/shield-controller@2.1.1 [2.1.0]: https://github.com/MetaMask/core/compare/@metamask/shield-controller@2.0.0...@metamask/shield-controller@2.1.0 diff --git a/packages/shield-controller/package.json b/packages/shield-controller/package.json index 960250dbe19..29584ef9452 100644 --- a/packages/shield-controller/package.json +++ b/packages/shield-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/shield-controller", - "version": "3.0.0", + "version": "4.0.0", "description": "Controller handling shield transaction coverage logic", "keywords": [ "MetaMask", @@ -51,6 +51,8 @@ "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.16.0", "@metamask/messenger": "^0.3.0", + "@metamask/signature-controller": "^38.0.0", + "@metamask/transaction-controller": "^62.5.0", "@metamask/utils": "^11.8.1", "cockatiel": "^3.1.2" }, @@ -59,8 +61,6 @@ "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/signature-controller": "^37.0.0", - "@metamask/transaction-controller": "^62.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -72,10 +72,6 @@ "typescript": "~5.3.3", "uuid": "^8.3.2" }, - "peerDependencies": { - "@metamask/signature-controller": "^37.0.0", - "@metamask/transaction-controller": "^62.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/shield-controller/src/ShieldController.test.ts b/packages/shield-controller/src/ShieldController.test.ts index 91e1904c60f..e4032962a09 100644 --- a/packages/shield-controller/src/ShieldController.test.ts +++ b/packages/shield-controller/src/ShieldController.test.ts @@ -1,14 +1,10 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { SignatureRequestStatus } from '@metamask/signature-controller'; import type { SignatureRequest } from '@metamask/signature-controller'; -import { - SignatureRequestStatus, - type SignatureControllerState, -} from '@metamask/signature-controller'; +import type { SignatureControllerState } from '@metamask/signature-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; -import { - TransactionStatus, - type TransactionControllerState, -} from '@metamask/transaction-controller'; +import type { TransactionControllerState } from '@metamask/transaction-controller'; import { ShieldController } from './ShieldController'; import type { NormalizeSignatureRequestFn } from './types'; diff --git a/packages/shield-controller/src/ShieldController.ts b/packages/shield-controller/src/ShieldController.ts index 296df877717..7bc6b60f296 100644 --- a/packages/shield-controller/src/ShieldController.ts +++ b/packages/shield-controller/src/ShieldController.ts @@ -1,18 +1,18 @@ -import { - BaseController, - type ControllerGetStateAction, - type ControllerStateChangeEvent, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; -import { - SignatureRequestStatus, - type SignatureRequest, - type SignatureStateChange, +import { SignatureRequestStatus } from '@metamask/signature-controller'; +import type { + SignatureRequest, + SignatureStateChange, } from '@metamask/signature-controller'; -import { - TransactionStatus, - type TransactionControllerStateChangeEvent, - type TransactionMeta, +import { TransactionStatus } from '@metamask/transaction-controller'; +import type { + TransactionControllerStateChangeEvent, + TransactionMeta, } from '@metamask/transaction-controller'; import { cloneDeep, isEqual } from 'lodash'; diff --git a/packages/shield-controller/src/backend.test.ts b/packages/shield-controller/src/backend.test.ts index 820986bc948..fa6d1cae82d 100644 --- a/packages/shield-controller/src/backend.test.ts +++ b/packages/shield-controller/src/backend.test.ts @@ -3,7 +3,11 @@ import { SignatureRequestType, } from '@metamask/signature-controller'; -import { parseSignatureRequestMethod, ShieldRemoteBackend } from './backend'; +import { + makeInitCoverageCheckBody, + parseSignatureRequestMethod, + ShieldRemoteBackend, +} from './backend'; import { SignTypedDataVersion } from './constants'; import { generateMockSignatureRequest, @@ -423,4 +427,41 @@ describe('ShieldRemoteBackend', () => { ); }); }); + + describe('makeInitCoverageCheckBody', () => { + it('makes init coverage check body', () => { + const txMeta = generateMockTxMeta(); + const body = makeInitCoverageCheckBody(txMeta); + expect(body).toMatchObject({ + txParams: [txMeta.txParams], + }); + }); + + it('makes init coverage check body with authorization list', () => { + const txMeta = generateMockTxMeta(); + const body = makeInitCoverageCheckBody({ + ...txMeta, + txParams: { + ...txMeta.txParams, + authorizationList: [ + { + address: '0x0000000000000000000000000000000000000000', + }, + ], + }, + }); + expect(body).toMatchObject({ + txParams: [ + { + ...txMeta.txParams, + authorizationList: [ + { + address: '0x0000000000000000000000000000000000000000', + }, + ], + }, + ], + }); + }); + }); }); diff --git a/packages/shield-controller/src/backend.ts b/packages/shield-controller/src/backend.ts index cd94bc910a7..033cff93def 100644 --- a/packages/shield-controller/src/backend.ts +++ b/packages/shield-controller/src/backend.ts @@ -6,9 +6,10 @@ import { import { EthMethod, SignatureRequestType, - type SignatureRequest, } from '@metamask/signature-controller'; +import type { SignatureRequest } from '@metamask/signature-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { AuthorizationList } from '@metamask/transaction-controller'; import type { Json } from '@metamask/utils'; import { SignTypedDataVersion } from './constants'; @@ -26,6 +27,7 @@ import type { export type InitCoverageCheckRequest = { txParams: [ { + authorizationList?: AuthorizationList; from: string; to?: string; value?: string; @@ -284,12 +286,13 @@ export class ShieldRemoteBackend implements ShieldBackend { * @param txMeta - The transaction metadata. * @returns The body for the init coverage check request. */ -function makeInitCoverageCheckBody( +export function makeInitCoverageCheckBody( txMeta: TransactionMeta, ): InitCoverageCheckRequest { return { txParams: [ { + authorizationList: txMeta.txParams.authorizationList, from: txMeta.txParams.from, to: txMeta.txParams.to, value: txMeta.txParams.value, diff --git a/packages/shield-controller/src/polling-with-policy.ts b/packages/shield-controller/src/polling-with-policy.ts index 05dc4cc2582..1c21f184dda 100644 --- a/packages/shield-controller/src/polling-with-policy.ts +++ b/packages/shield-controller/src/polling-with-policy.ts @@ -1,8 +1,7 @@ -import { - createServicePolicy, - HttpError, - type CreateServicePolicyOptions, - type ServicePolicy, +import { createServicePolicy, HttpError } from '@metamask/controller-utils'; +import type { + CreateServicePolicyOptions, + ServicePolicy, } from '@metamask/controller-utils'; import { handleWhen } from 'cockatiel'; diff --git a/packages/shield-controller/tests/mocks/messenger.ts b/packages/shield-controller/tests/mocks/messenger.ts index b427b22a8fb..19e37e52d15 100644 --- a/packages/shield-controller/tests/mocks/messenger.ts +++ b/packages/shield-controller/tests/mocks/messenger.ts @@ -1,12 +1,11 @@ -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; -import { type ShieldControllerMessenger } from '../../src'; +import type { ShieldControllerMessenger } from '../../src'; import { controllerName } from '../../src/constants'; type AllShieldControllerActions = MessengerActions; diff --git a/packages/shield-controller/tests/utils.ts b/packages/shield-controller/tests/utils.ts index 5243d32804c..9d06727b616 100644 --- a/packages/shield-controller/tests/utils.ts +++ b/packages/shield-controller/tests/utils.ts @@ -1,18 +1,19 @@ import { SignatureRequestStatus, SignatureRequestType, - type SignatureRequest, } from '@metamask/signature-controller'; +import type { SignatureRequest } from '@metamask/signature-controller'; import { TransactionStatus, TransactionType, - type TransactionMeta, } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { SignTypedDataVersion } from 'src/constants'; import { v1 as random } from 'uuid'; import type { createMockMessenger } from './mocks/messenger'; -import { coverageStatuses, type CoverageStatus } from '../src/types'; +import { coverageStatuses } from '../src/types'; +import type { CoverageStatus } from '../src/types'; /** * Generate a mock transaction meta. diff --git a/packages/signature-controller/CHANGELOG.md b/packages/signature-controller/CHANGELOG.md index 021616f48bc..0a62bd675d4 100644 --- a/packages/signature-controller/CHANGELOG.md +++ b/packages/signature-controller/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [38.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/gator-permissions-controller` from `^0.6.0` to `^0.8.0` ([#7274](https://github.com/MetaMask/core/pull/7274)), ([#7330](https://github.com/MetaMask/core/pull/7330)) +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7258](https://github.com/MetaMask/core/pull/7258)) + - The dependencies moved are: + - `@metamask/accounts-controller` (^35.0.0) + - `@metamask/approval-controller` (^8.0.0) + - `@metamask/gator-permissions-controller` (^0.6.0) + - `@metamask/keyring-controller` (^25.0.0) + - `@metamask/logging-controller` (^7.0.1) + - `@metamask/network-controller` (^27.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [37.0.0] ### Changed @@ -622,7 +639,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#1214](https://github.com/MetaMask/core/pull/1214)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@37.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@38.0.0...HEAD +[38.0.0]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@37.0.0...@metamask/signature-controller@38.0.0 [37.0.0]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@36.0.0...@metamask/signature-controller@37.0.0 [36.0.0]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@35.0.0...@metamask/signature-controller@36.0.0 [35.0.0]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@34.0.2...@metamask/signature-controller@35.0.0 diff --git a/packages/signature-controller/package.json b/packages/signature-controller/package.json index e88787e5335..3ad7c1417a0 100644 --- a/packages/signature-controller/package.json +++ b/packages/signature-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/signature-controller", - "version": "37.0.0", + "version": "38.0.0", "description": "Processes signing requests in order to sign arbitrary and typed data", "keywords": [ "MetaMask", @@ -48,23 +48,23 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/accounts-controller": "^35.0.0", + "@metamask/approval-controller": "^8.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.16.0", "@metamask/eth-sig-util": "^8.2.0", + "@metamask/gator-permissions-controller": "^0.8.0", + "@metamask/keyring-controller": "^25.0.0", + "@metamask/logging-controller": "^7.0.1", "@metamask/messenger": "^0.3.0", + "@metamask/network-controller": "^27.0.0", "@metamask/utils": "^11.8.1", "jsonschema": "^1.4.1", "lodash": "^4.17.21", "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/accounts-controller": "^35.0.0", - "@metamask/approval-controller": "^8.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/gator-permissions-controller": "^0.6.0", - "@metamask/keyring-controller": "^25.0.0", - "@metamask/logging-controller": "^7.0.1", - "@metamask/network-controller": "^26.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -74,14 +74,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/accounts-controller": "^35.0.0", - "@metamask/approval-controller": "^8.0.0", - "@metamask/gator-permissions-controller": "^0.6.0", - "@metamask/keyring-controller": "^25.0.0", - "@metamask/logging-controller": "^7.0.0", - "@metamask/network-controller": "^26.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/signature-controller/src/SignatureController.test.ts b/packages/signature-controller/src/SignatureController.test.ts index a18a67da64e..379e72a2edb 100644 --- a/packages/signature-controller/src/SignatureController.test.ts +++ b/packages/signature-controller/src/SignatureController.test.ts @@ -873,7 +873,7 @@ describe('SignatureController', () => { data: JSON.stringify({ test: 123 }), }, REQUEST_MOCK, - version as SignTypedDataVersion, + version, { parseJsonData: true }, ); diff --git a/packages/signature-controller/src/SignatureController.ts b/packages/signature-controller/src/SignatureController.ts index e975fa5564a..9dc134e34a0 100644 --- a/packages/signature-controller/src/SignatureController.ts +++ b/packages/signature-controller/src/SignatureController.ts @@ -4,10 +4,10 @@ import type { AcceptResultCallbacks, AddResult, } from '@metamask/approval-controller'; -import { - BaseController, - type ControllerGetStateAction, - type ControllerStateChangeEvent, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; import type { TraceCallback, TraceContext } from '@metamask/controller-utils'; import { @@ -29,8 +29,8 @@ import { SigningMethod, LogType, SigningStage, - type AddLog, } from '@metamask/logging-controller'; +import type { AddLog } from '@metamask/logging-controller'; import type { Messenger } from '@metamask/messenger'; import type { NetworkControllerGetNetworkClientByIdAction } from '@metamask/network-controller'; import type { Hex, Json } from '@metamask/utils'; @@ -414,7 +414,7 @@ export class SignatureController extends BaseController< const normalizedMessageParams = normalizeTypedMessageParams( messageParams, - version as SignTypedDataVersion, + version, ); return this.#processSignatureRequest({ diff --git a/packages/signature-controller/src/utils/decoding-api.test.ts b/packages/signature-controller/src/utils/decoding-api.test.ts index f3ae0d5e8f1..9c898b4f9af 100644 --- a/packages/signature-controller/src/utils/decoding-api.test.ts +++ b/packages/signature-controller/src/utils/decoding-api.test.ts @@ -1,5 +1,6 @@ import { decodeSignature } from './decoding-api'; -import { EthMethod, type OriginalRequest } from '../types'; +import { EthMethod } from '../types'; +import type { OriginalRequest } from '../types'; const PERMIT_REQUEST_MOCK = { method: EthMethod.SignTypedDataV4, diff --git a/packages/signature-controller/src/utils/decoding-api.ts b/packages/signature-controller/src/utils/decoding-api.ts index bbe184414e3..f886c23a606 100644 --- a/packages/signature-controller/src/utils/decoding-api.ts +++ b/packages/signature-controller/src/utils/decoding-api.ts @@ -1,5 +1,6 @@ import { normalizeParam } from './normalize'; -import { EthMethod, type OriginalRequest } from '../types'; +import { EthMethod } from '../types'; +import type { OriginalRequest } from '../types'; export const DECODING_API_ERRORS = { UNSUPPORTED_SIGNATURE: 'UNSUPPORTED_SIGNATURE', @@ -48,7 +49,7 @@ export async function decodeSignature( } catch (error: unknown) { return { error: { - message: (error as unknown as Error).message, + message: (error as Error).message, type: DECODING_API_ERRORS.DECODING_FAILED_WITH_ERROR, }, }; diff --git a/packages/signature-controller/src/utils/normalize.ts b/packages/signature-controller/src/utils/normalize.ts index 4ae9443138d..4c06c439acb 100644 --- a/packages/signature-controller/src/utils/normalize.ts +++ b/packages/signature-controller/src/utils/normalize.ts @@ -1,5 +1,6 @@ import { SignTypedDataVersion } from '@metamask/keyring-controller'; -import { add0x, bytesToHex, type Json, remove0x } from '@metamask/utils'; +import { add0x, bytesToHex, remove0x } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; import type { MessageParamsPersonal, MessageParamsTyped } from '../types'; diff --git a/packages/signature-controller/src/utils/validation.ts b/packages/signature-controller/src/utils/validation.ts index 549e33af2eb..d846dd2d093 100644 --- a/packages/signature-controller/src/utils/validation.ts +++ b/packages/signature-controller/src/utils/validation.ts @@ -7,7 +7,7 @@ import { import type { DecodedPermission } from '@metamask/gator-permissions-controller'; import { SignTypedDataVersion } from '@metamask/keyring-controller'; import type { Json } from '@metamask/utils'; -import { type Hex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { validate } from 'jsonschema'; import { isDelegationRequest } from './delegations'; diff --git a/packages/storage-service/CHANGELOG.md b/packages/storage-service/CHANGELOG.md new file mode 100644 index 00000000000..9370419d25d --- /dev/null +++ b/packages/storage-service/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release of `@metamask/storage-service` ([#7192](https://github.com/MetaMask/core/pull/7192)) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/storage-service/LICENSE b/packages/storage-service/LICENSE new file mode 100644 index 00000000000..f9f85c6d4ec --- /dev/null +++ b/packages/storage-service/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. diff --git a/packages/storage-service/LICENSE.APACHE2 b/packages/storage-service/LICENSE.APACHE2 new file mode 100644 index 00000000000..cd780528412 --- /dev/null +++ b/packages/storage-service/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 MetaMask + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/storage-service/LICENSE.MIT b/packages/storage-service/LICENSE.MIT new file mode 100644 index 00000000000..97a3ce1c090 --- /dev/null +++ b/packages/storage-service/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/storage-service/README.md b/packages/storage-service/README.md new file mode 100644 index 00000000000..f38f4aadb78 --- /dev/null +++ b/packages/storage-service/README.md @@ -0,0 +1,131 @@ +# `@metamask/storage-service` + +A platform-agnostic service for storing large, infrequently accessed controller data outside of memory. + +## When to Use + +✅ **Use StorageService for:** + +- Large data (> 100 KB) +- Infrequently accessed data +- Data that doesn't need to be in Redux state +- Examples: Snap source code, cached API responses + +❌ **Don't use for:** + +- Frequently accessed data (use controller state) +- Small data (< 10 KB - overhead not worth it) +- Data needed for UI rendering + +## Installation + +`yarn add @metamask/storage-service` + +or + +`npm install @metamask/storage-service` + +## Usage + +### Controller Setup + +```typescript +import type { + StorageServiceSetItemAction, + StorageServiceGetItemAction, +} from '@metamask/storage-service'; + +// Grant access to storage actions +type AllowedActions = + | StorageServiceSetItemAction + | StorageServiceGetItemAction; + +class MyController extends BaseController<...> { + async storeData(id: string, data: string) { + await this.messenger.call( + 'StorageService:setItem', + 'MyController', + `${id}:data`, + data, + ); + } + + async getData(id: string): Promise { + const { result, error } = await this.messenger.call( + 'StorageService:getItem', + 'MyController', + `${id}:data`, + ); + if (error) { + throw error; + } + // result is undefined if key doesn't exist + return result as string | undefined; + } +} +``` + +### Service Initialization + +The service accepts an optional `StorageAdapter` for platform-specific storage: + +```typescript +import { StorageService, type StorageAdapter } from '@metamask/storage-service'; + +// Production: Provide a platform-specific adapter +const service = new StorageService({ + messenger: storageServiceMessenger, + storage: myPlatformAdapter, // FilesystemStorage, IndexedDB, etc. +}); + +// Testing: Uses in-memory storage by default +const testService = new StorageService({ + messenger: testMessenger, + // No adapter needed - data isolated per test +}); +``` + +### Events + +Subscribe to storage changes: + +```typescript +this.messenger.subscribe( + 'StorageService:itemSet:MyController', + (key, value) => { + console.log(`Data stored: ${key}`); + }, +); +``` + +## StorageAdapter Interface + +Implement this interface to provide platform-specific storage: + +```typescript +import type { Json } from '@metamask/utils'; + +// Response type for getItem - distinguishes found, not found, and error +type StorageGetResult = + | { result: Json; error?: never } // Data found + | { result?: never; error: Error } // Error occurred + | Record; // Key doesn't exist (empty object) + +export type StorageAdapter = { + getItem(namespace: string, key: string): Promise; + setItem(namespace: string, key: string, value: Json): Promise; + removeItem(namespace: string, key: string): Promise; + getAllKeys(namespace: string): Promise; + clear(namespace: string): Promise; +}; +``` + +Adapters are responsible for: + +- Building the full storage key (e.g., `storageService:namespace:key`) +- Serializing/deserializing JSON data +- Returning the correct response format for getItem + +## 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/storage-service/jest.config.js b/packages/storage-service/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/storage-service/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/storage-service/package.json b/packages/storage-service/package.json new file mode 100644 index 00000000000..0581b748a83 --- /dev/null +++ b/packages/storage-service/package.json @@ -0,0 +1,72 @@ +{ + "name": "@metamask/storage-service", + "version": "0.0.0", + "description": "Platform-agnostic service for storing large, infrequently accessed controller data", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/storage-service#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "(MIT OR Apache-2.0)", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/storage-service", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/storage-service", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/messenger": "^0.3.0", + "@metamask/utils": "^11.8.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/storage-service/src/InMemoryStorageAdapter.test.ts b/packages/storage-service/src/InMemoryStorageAdapter.test.ts new file mode 100644 index 00000000000..2ff088c9b00 --- /dev/null +++ b/packages/storage-service/src/InMemoryStorageAdapter.test.ts @@ -0,0 +1,233 @@ +import { InMemoryStorageAdapter } from './InMemoryStorageAdapter'; + +describe('InMemoryStorageAdapter', () => { + describe('getItem', () => { + it('returns empty object {} for non-existent keys', async () => { + const adapter = new InMemoryStorageAdapter(); + + const response = await adapter.getItem('TestNamespace', 'nonExistent'); + + expect(response).toStrictEqual({}); + }); + + it('returns { result } with previously stored values', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'testKey', 'testValue'); + const response = await adapter.getItem('TestNamespace', 'testKey'); + + expect(response).toStrictEqual({ result: 'testValue' }); + }); + + it('returns { result: null } when null was explicitly stored', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'nullKey', null); + const response = await adapter.getItem('TestNamespace', 'nullKey'); + + // This is different from {} - data WAS found, and it was null + expect(response).toStrictEqual({ result: null }); + }); + + it('returns { error } when stored data is corrupted', async () => { + const adapter = new InMemoryStorageAdapter(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const parseError = new SyntaxError('Unexpected token'); + + // Store valid data first + await adapter.setItem('TestNamespace', 'corruptKey', 'validValue'); + + // Mock JSON.parse to throw on the next call (simulating corruption) + const originalParse = JSON.parse; + jest.spyOn(JSON, 'parse').mockImplementationOnce(() => { + throw parseError; + }); + + const response = await adapter.getItem('TestNamespace', 'corruptKey'); + + expect(response).toStrictEqual({ error: parseError }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to parse stored data'), + parseError, + ); + + // Restore + JSON.parse = originalParse; + consoleErrorSpy.mockRestore(); + }); + }); + + describe('setItem', () => { + it('stores a value that can be retrieved', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'key', 'value'); + const response = await adapter.getItem('TestNamespace', 'key'); + + expect(response).toStrictEqual({ result: 'value' }); + }); + + it('stores objects', async () => { + const adapter = new InMemoryStorageAdapter(); + const obj = { foo: 'bar', num: 123 }; + + await adapter.setItem('TestNamespace', 'key', obj); + const response = await adapter.getItem('TestNamespace', 'key'); + + expect(response).toStrictEqual({ result: obj }); + }); + + it('stores strings', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'key', 'string value'); + const response = await adapter.getItem('TestNamespace', 'key'); + + expect(response).toStrictEqual({ result: 'string value' }); + }); + + it('stores numbers', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'key', 42); + const response = await adapter.getItem('TestNamespace', 'key'); + + expect(response).toStrictEqual({ result: 42 }); + }); + + it('stores booleans', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'key', true); + const response = await adapter.getItem('TestNamespace', 'key'); + + expect(response).toStrictEqual({ result: true }); + }); + + it('overwrites existing values', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'key', 'oldValue'); + await adapter.setItem('TestNamespace', 'key', 'newValue'); + const response = await adapter.getItem('TestNamespace', 'key'); + + expect(response).toStrictEqual({ result: 'newValue' }); + }); + }); + + describe('removeItem', () => { + it('removes a stored item', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'key', 'value'); + await adapter.removeItem('TestNamespace', 'key'); + const response = await adapter.getItem('TestNamespace', 'key'); + + // After removal, key doesn't exist - returns empty object + expect(response).toStrictEqual({}); + }); + + it('does not throw when removing non-existent key', async () => { + const adapter = new InMemoryStorageAdapter(); + + const result = await adapter.removeItem('TestNamespace', 'nonExistent'); + expect(result).toBeUndefined(); + }); + }); + + describe('getAllKeys', () => { + it('returns keys for a namespace', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'key1', 'value1'); + await adapter.setItem('TestNamespace', 'key2', 'value2'); + await adapter.setItem('OtherNamespace', 'key3', 'value3'); + + const keys = await adapter.getAllKeys('TestNamespace'); + + expect(keys).toStrictEqual(expect.arrayContaining(['key1', 'key2'])); + expect(keys).toHaveLength(2); + }); + + it('returns empty array when no keys for namespace', async () => { + const adapter = new InMemoryStorageAdapter(); + + const keys = await adapter.getAllKeys('EmptyNamespace'); + + expect(keys).toStrictEqual([]); + }); + + it('strips prefix from returned keys', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'my-key', 'value'); + + const keys = await adapter.getAllKeys('TestNamespace'); + + expect(keys).toStrictEqual(['my-key']); + expect(keys[0]).not.toContain('storageService:'); + expect(keys[0]).not.toContain('TestNamespace:'); + }); + }); + + describe('clear', () => { + it('removes all items for a namespace', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'key1', 'value1'); + await adapter.setItem('TestNamespace', 'key2', 'value2'); + await adapter.setItem('OtherNamespace', 'key3', 'value3'); + + await adapter.clear('TestNamespace'); + + const testKeys = await adapter.getAllKeys('TestNamespace'); + const otherKeys = await adapter.getAllKeys('OtherNamespace'); + + expect(testKeys).toStrictEqual([]); + expect(otherKeys).toStrictEqual(['key3']); + }); + + it('does not affect other namespaces', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('Namespace1', 'key', 'value1'); + await adapter.setItem('Namespace2', 'key', 'value2'); + + await adapter.clear('Namespace1'); + + expect(await adapter.getItem('Namespace1', 'key')).toStrictEqual({}); + expect(await adapter.getItem('Namespace2', 'key')).toStrictEqual({ + result: 'value2', + }); + }); + }); + + describe('data isolation', () => { + it('different instances have isolated storage', async () => { + const adapter1 = new InMemoryStorageAdapter(); + const adapter2 = new InMemoryStorageAdapter(); + + await adapter1.setItem('TestNamespace', 'key', 'value1'); + await adapter2.setItem('TestNamespace', 'key', 'value2'); + + expect(await adapter1.getItem('TestNamespace', 'key')).toStrictEqual({ + result: 'value1', + }); + expect(await adapter2.getItem('TestNamespace', 'key')).toStrictEqual({ + result: 'value2', + }); + }); + }); + + describe('implements StorageAdapter interface', () => { + it('implements all required methods', () => { + const adapter = new InMemoryStorageAdapter(); + + expect(typeof adapter.getItem).toBe('function'); + expect(typeof adapter.setItem).toBe('function'); + expect(typeof adapter.removeItem).toBe('function'); + expect(typeof adapter.getAllKeys).toBe('function'); + expect(typeof adapter.clear).toBe('function'); + }); + }); +}); diff --git a/packages/storage-service/src/InMemoryStorageAdapter.ts b/packages/storage-service/src/InMemoryStorageAdapter.ts new file mode 100644 index 00000000000..07ef4c80514 --- /dev/null +++ b/packages/storage-service/src/InMemoryStorageAdapter.ts @@ -0,0 +1,119 @@ +import type { Json } from '@metamask/utils'; + +import type { StorageAdapter, StorageGetResult } from './types'; +import { STORAGE_KEY_PREFIX } from './types'; + +/** + * In-memory storage adapter (default fallback). + * Implements the {@link StorageAdapter} interface using a Map. + * + * ⚠️ **Warning**: Data is NOT persisted - lost on restart. + * + * **Suitable for:** + * - Testing (isolated, no mocking needed) + * - Development (quick start, zero config) + * - Temporary/ephemeral data + * + * **Not suitable for:** + * - Production (unless data is truly ephemeral) + * - Data that needs to persist across restarts + * + * @example + * ```typescript + * const adapter = new InMemoryStorageAdapter(); + * await adapter.setItem('SnapController', 'snap-id:sourceCode', 'const x = 1;'); + * const value = await adapter.getItem('SnapController', 'snap-id:sourceCode'); // 'const x = 1;' + * // After restart: data is lost + * ``` + */ +export class InMemoryStorageAdapter implements StorageAdapter { + // Explicitly implement StorageAdapter interface + /** + * Internal storage map. + */ + readonly #storage: Map; + + /** + * Constructs a new InMemoryStorageAdapter. + */ + constructor() { + this.#storage = new Map(); + } + + /** + * Retrieve an item from in-memory storage. + * Deserializes JSON data from storage. + * + * @param namespace - The controller namespace. + * @param key - The data key. + * @returns StorageGetResult: { result } if found, {} if not found, { error } on failure. + */ + async getItem(namespace: string, key: string): Promise { + const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`; + const serialized = this.#storage.get(fullKey); + + // Key not found - return empty object + if (serialized === undefined) { + return {}; + } + + try { + const result = JSON.parse(serialized); + return { result }; + } catch (error) { + console.error(`Failed to parse stored data for ${fullKey}:`, error); + return { error: error as Error }; + } + } + + /** + * Store an item in in-memory storage. + * Serializes JSON data to string. + * + * @param namespace - The controller namespace. + * @param key - The data key. + * @param value - The JSON value to store. + */ + async setItem(namespace: string, key: string, value: Json): Promise { + const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`; + this.#storage.set(fullKey, JSON.stringify(value)); + } + + /** + * Remove an item from in-memory storage. + * + * @param namespace - The controller namespace. + * @param key - The data key. + */ + async removeItem(namespace: string, key: string): Promise { + const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`; + this.#storage.delete(fullKey); + } + + /** + * Get all keys for a namespace. + * Returns keys without the 'storage:namespace:' prefix. + * + * @param namespace - The namespace to get keys for. + * @returns Array of keys (without prefix) for this namespace. + */ + async getAllKeys(namespace: string): Promise { + const prefix = `${STORAGE_KEY_PREFIX}${namespace}:`; + return Array.from(this.#storage.keys()) + .filter((key) => key.startsWith(prefix)) + .map((key) => key.slice(prefix.length)); + } + + /** + * Clear all items for a namespace. + * + * @param namespace - The namespace to clear. + */ + async clear(namespace: string): Promise { + const prefix = `${STORAGE_KEY_PREFIX}${namespace}:`; + const keysToDelete = Array.from(this.#storage.keys()).filter((key) => + key.startsWith(prefix), + ); + keysToDelete.forEach((key) => this.#storage.delete(key)); + } +} diff --git a/packages/storage-service/src/StorageService-method-action-types.ts b/packages/storage-service/src/StorageService-method-action-types.ts new file mode 100644 index 00000000000..e6a033d0249 --- /dev/null +++ b/packages/storage-service/src/StorageService-method-action-types.ts @@ -0,0 +1,89 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { StorageService } from './StorageService'; + +/** + * Store large JSON data in storage. + * + * ⚠️ **Designed for large values (100KB+), not many small ones.** + * Each storage operation has I/O overhead. For best performance, + * store one large object rather than many small key-value pairs. + * + * @example Good: Store entire cache as one value + * ```typescript + * await service.setItem('TokenList', 'cache', { '0x1': [...], '0x38': [...] }); + * ``` + * + * @example Avoid: Many small values + * ```typescript + * // ❌ Don't do this - too many small writes + * await service.setItem('TokenList', 'cache:0x1', [...]); + * await service.setItem('TokenList', 'cache:0x38', [...]); + * ``` + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + * @param key - Storage key (e.g., 'npm:@metamask/example-snap:sourceCode'). + * @param value - JSON data to store (should be 100KB+ for optimal use). + */ +export type StorageServiceSetItemAction = { + type: `StorageService:setItem`; + handler: StorageService['setItem']; +}; + +/** + * Retrieve JSON data from storage. + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + * @param key - Storage key (e.g., 'npm:@metamask/example-snap:sourceCode'). + * @returns StorageGetResult: { result } if found, {} if not found, { error } on failure. + */ +export type StorageServiceGetItemAction = { + type: `StorageService:getItem`; + handler: StorageService['getItem']; +}; + +/** + * Remove data from storage. + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + * @param key - Storage key (e.g., 'npm:@metamask/example-snap:sourceCode'). + */ +export type StorageServiceRemoveItemAction = { + type: `StorageService:removeItem`; + handler: StorageService['removeItem']; +}; + +/** + * Get all keys for a namespace. + * Delegates to storage adapter which handles filtering. + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + * @returns Array of keys (without prefix) for this namespace. + */ +export type StorageServiceGetAllKeysAction = { + type: `StorageService:getAllKeys`; + handler: StorageService['getAllKeys']; +}; + +/** + * Clear all data for a namespace. + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + */ +export type StorageServiceClearAction = { + type: `StorageService:clear`; + handler: StorageService['clear']; +}; + +/** + * Union of all StorageService action types. + */ +export type StorageServiceMethodActions = + | StorageServiceSetItemAction + | StorageServiceGetItemAction + | StorageServiceRemoveItemAction + | StorageServiceGetAllKeysAction + | StorageServiceClearAction; diff --git a/packages/storage-service/src/StorageService.test.ts b/packages/storage-service/src/StorageService.test.ts new file mode 100644 index 00000000000..2efa05b147b --- /dev/null +++ b/packages/storage-service/src/StorageService.test.ts @@ -0,0 +1,629 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import { StorageService } from './StorageService'; +import type { StorageServiceMessenger, StorageAdapter } from './types'; + +describe('StorageService', () => { + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + describe('constructor', () => { + it('uses provided storage adapter', () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue([]), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + expect(service).toBeInstanceOf(StorageService); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('defaults to InMemoryStorageAdapter when no storage provided', () => { + const { service } = getService(); + + expect(service).toBeInstanceOf(StorageService); + }); + + it('logs warning when using in-memory storage', () => { + getService(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('No storage adapter provided'), + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Data will be lost on restart'), + ); + }); + }); + + describe('setItem', () => { + it('delegates to adapter with namespace and key', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue([]), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + await service.setItem('TestController', 'testKey', 'testValue'); + + // Adapter receives namespace and key separately (adapter handles key building) + expect(mockStorage.setItem).toHaveBeenCalledWith( + 'TestController', + 'testKey', + 'testValue', + ); + }); + + it('passes complex objects to adapter', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue([]), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + const complexObject = { foo: 'bar', nested: { value: 123 } }; + + await service.setItem('TestController', 'complex', complexObject); + + // Adapter handles serialization + expect(mockStorage.setItem).toHaveBeenCalledWith( + 'TestController', + 'complex', + complexObject, + ); + }); + + it('handles storage errors gracefully', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn(), + setItem: jest + .fn() + .mockRejectedValue(new Error('Storage quota exceeded')), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue([]), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + await expect( + service.setItem('TestController', 'key', 'value'), + ).rejects.toThrow('Storage quota exceeded'); + }); + + it('publishes itemSet event with key and value', async () => { + const { service, rootMessenger } = getService(); + const eventHandler = jest.fn(); + + rootMessenger.subscribe( + 'StorageService:itemSet:TestController' as `StorageService:itemSet:${string}`, + eventHandler, + ); + + await service.setItem('TestController', 'myKey', { data: 'test' }); + + expect(eventHandler).toHaveBeenCalledTimes(1); + expect(eventHandler).toHaveBeenCalledWith('myKey', { data: 'test' }); + }); + + it('publishes itemSet event only for matching namespace', async () => { + const { service, rootMessenger } = getService(); + const controller1Handler = jest.fn(); + + rootMessenger.subscribe( + 'StorageService:itemSet:Controller1' as `StorageService:itemSet:${string}`, + controller1Handler, + ); + + await service.setItem('Controller1', 'key', 'value1'); + await service.setItem('Controller2', 'key', 'value2'); + + expect(controller1Handler).toHaveBeenCalledTimes(1); + expect(controller1Handler).toHaveBeenCalledWith('key', 'value1'); + }); + }); + + describe('getItem', () => { + it('returns { result } with stored data when key exists', async () => { + const { service } = getService(); + + await service.setItem('TestController', 'testKey', { data: 'test' }); + const response = await service.getItem('TestController', 'testKey'); + + expect(response).toStrictEqual({ result: { data: 'test' } }); + }); + + it('returns empty object {} for non-existent keys', async () => { + const { service } = getService(); + + const response = await service.getItem('TestController', 'nonExistent'); + + expect(response).toStrictEqual({}); + }); + + it('returns empty object {} when adapter returns not found', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn().mockResolvedValue({}), // Adapter returns {} for not found + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue([]), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + const response = await service.getItem('TestController', 'missing'); + + expect(response).toStrictEqual({}); + expect(mockStorage.getItem).toHaveBeenCalledWith( + 'TestController', + 'missing', + ); + }); + + it('returns { error } when adapter returns error', async () => { + const testError = new Error('Parse error'); + const mockStorage: StorageAdapter = { + getItem: jest.fn().mockResolvedValue({ error: testError }), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue([]), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + const response = await service.getItem('TestController', 'corrupt'); + + expect(response).toStrictEqual({ error: testError }); + }); + + it('returns { result } with string values', async () => { + const { service } = getService(); + + await service.setItem('TestController', 'string', 'simple string'); + const response = await service.getItem('TestController', 'string'); + + expect(response).toStrictEqual({ result: 'simple string' }); + }); + + it('returns { result } with number values', async () => { + const { service } = getService(); + + await service.setItem('TestController', 'number', 42); + const response = await service.getItem('TestController', 'number'); + + expect(response).toStrictEqual({ result: 42 }); + }); + + it('returns { result } with array values', async () => { + const { service } = getService(); + const array = [1, 2, 3]; + + await service.setItem('TestController', 'array', array); + const response = await service.getItem('TestController', 'array'); + + expect(response).toStrictEqual({ result: array }); + }); + + it('returns { result: null } when null was explicitly stored', async () => { + const { service } = getService(); + + await service.setItem('TestController', 'nullValue', null); + const response = await service.getItem('TestController', 'nullValue'); + + // This is different from {} - data WAS found, and it was null + expect(response).toStrictEqual({ result: null }); + }); + }); + + describe('removeItem', () => { + it('removes data from storage', async () => { + const { service } = getService(); + + await service.setItem('TestController', 'toRemove', 'value'); + await service.removeItem('TestController', 'toRemove'); + const response = await service.getItem('TestController', 'toRemove'); + + // After removal, key doesn't exist - returns empty object + expect(response).toStrictEqual({}); + }); + + it('removes key from registry', async () => { + const { service } = getService(); + + await service.setItem('TestController', 'key1', 'value1'); + await service.setItem('TestController', 'key2', 'value2'); + await service.removeItem('TestController', 'key1'); + const keys = await service.getAllKeys('TestController'); + + expect(keys).toStrictEqual(['key2']); + }); + }); + + describe('getAllKeys', () => { + it('delegates to storage adapter with namespace', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue(['key1', 'key2']), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + const keys = await service.getAllKeys('TestController'); + + expect(mockStorage.getAllKeys).toHaveBeenCalledWith('TestController'); + expect(keys).toStrictEqual(['key1', 'key2']); + }); + + it('returns keys from default in-memory adapter', async () => { + const { service } = getService(); // Uses InMemoryAdapter + + await service.setItem('TestController', 'key1', 'value1'); + await service.setItem('TestController', 'key2', 'value2'); + await service.setItem('OtherController', 'key3', 'value3'); + + const keys = await service.getAllKeys('TestController'); + + expect(keys).toStrictEqual(expect.arrayContaining(['key1', 'key2'])); + expect(keys).toHaveLength(2); + }); + + it('returns empty array for namespace with no keys', async () => { + const { service } = getService(); + + const keys = await service.getAllKeys('EmptyController'); + + expect(keys).toStrictEqual([]); + }); + + it('delegates to adapter for namespace filtering', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue([]), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + const keys = await service.getAllKeys('NonExistentController'); + + expect(mockStorage.getAllKeys).toHaveBeenCalledWith( + 'NonExistentController', + ); + expect(keys).toStrictEqual([]); + }); + }); + + describe('clear', () => { + it('delegates to storage adapter', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue([]), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + await service.clear('TestController'); + + expect(mockStorage.clear).toHaveBeenCalledWith('TestController'); + }); + + it('clears namespace using default in-memory adapter', async () => { + const { service } = getService(); + + await service.setItem('TestController', 'key1', 'value1'); + await service.setItem('TestController', 'key2', 'value2'); + await service.setItem('OtherController', 'key3', 'value3'); + + await service.clear('TestController'); + + const testKeys = await service.getAllKeys('TestController'); + const otherKeys = await service.getAllKeys('OtherController'); + + expect(testKeys).toStrictEqual([]); + expect(otherKeys).toStrictEqual(['key3']); + }); + + it('does not affect other namespaces', async () => { + const { service } = getService(); + + await service.setItem('Controller1', 'key', 'value1'); + await service.setItem('Controller2', 'key', 'value2'); + await service.setItem('Controller3', 'key', 'value3'); + + await service.clear('Controller2'); + + expect(await service.getItem('Controller1', 'key')).toStrictEqual({ + result: 'value1', + }); + expect(await service.getItem('Controller2', 'key')).toStrictEqual({}); + expect(await service.getItem('Controller3', 'key')).toStrictEqual({ + result: 'value3', + }); + }); + }); + + describe('namespace isolation', () => { + it('prevents key collisions between namespaces', async () => { + const { service } = getService(); + + await service.setItem('Controller1', 'sameKey', 'value1'); + await service.setItem('Controller2', 'sameKey', 'value2'); + + const response1 = await service.getItem('Controller1', 'sameKey'); + const response2 = await service.getItem('Controller2', 'sameKey'); + + expect(response1).toStrictEqual({ result: 'value1' }); + expect(response2).toStrictEqual({ result: 'value2' }); + }); + + it('getAllKeys only returns keys for specified namespace', async () => { + const { service } = getService(); + + await service.setItem('SnapController', 'snap1', 'data1'); + await service.setItem('SnapController', 'snap2', 'data2'); + await service.setItem('TokensController', 'token1', 'data3'); + + const snapKeys = await service.getAllKeys('SnapController'); + const tokenKeys = await service.getAllKeys('TokensController'); + + expect(snapKeys).toStrictEqual( + expect.arrayContaining(['snap1', 'snap2']), + ); + expect(snapKeys).toHaveLength(2); + expect(tokenKeys).toStrictEqual(['token1']); + }); + }); + + describe('messenger actions', () => { + it('exposes setItem as messenger action', async () => { + const { rootMessenger } = getService(); + + await rootMessenger.call( + 'StorageService:setItem', + 'TestController', + 'key', + 'value', + ); + + const response = await rootMessenger.call( + 'StorageService:getItem', + 'TestController', + 'key', + ); + + expect(response).toStrictEqual({ result: 'value' }); + }); + + it('exposes getItem as messenger action', async () => { + const { service, rootMessenger } = getService(); + + await service.setItem('TestController', 'key', 'value'); + + const response = await rootMessenger.call( + 'StorageService:getItem', + 'TestController', + 'key', + ); + + expect(response).toStrictEqual({ result: 'value' }); + }); + + it('exposes removeItem as messenger action', async () => { + const { service, rootMessenger } = getService(); + + await service.setItem('TestController', 'key', 'value'); + await rootMessenger.call( + 'StorageService:removeItem', + 'TestController', + 'key', + ); + + const response = await service.getItem('TestController', 'key'); + + expect(response).toStrictEqual({}); + }); + + it('exposes getAllKeys as messenger action', async () => { + const { service, rootMessenger } = getService(); + + await service.setItem('TestController', 'key1', 'value1'); + await service.setItem('TestController', 'key2', 'value2'); + + const keys = await rootMessenger.call( + 'StorageService:getAllKeys', + 'TestController', + ); + + expect(keys).toStrictEqual(expect.arrayContaining(['key1', 'key2'])); + }); + + it('exposes clear as messenger action', async () => { + const { service, rootMessenger } = getService(); + + await service.setItem('TestController', 'key1', 'value1'); + await service.setItem('TestController', 'key2', 'value2'); + + await rootMessenger.call('StorageService:clear', 'TestController'); + + const keys = await service.getAllKeys('TestController'); + + expect(keys).toStrictEqual([]); + }); + }); + + describe('real-world usage scenario', () => { + it('simulates SnapController storing and retrieving source code', async () => { + const { service } = getService(); + + // Simulate storing 5 snap source codes (like production) + const snaps = { + 'npm:@metamask/bitcoin-wallet-snap': { + sourceCode: 'a'.repeat(3864960), + }, // ~3.86 MB + 'npm:@metamask/tron-wallet-snap': { sourceCode: 'b'.repeat(1089930) }, // ~1.09 MB + 'npm:@metamask/solana-wallet-snap': { sourceCode: 'c'.repeat(603890) }, // ~603 KB + 'npm:@metamask/ens-resolver-snap': { sourceCode: 'd'.repeat(371590) }, // ~371 KB + 'npm:@metamask/message-signing-snap': { + sourceCode: 'e'.repeat(159030), + }, // ~159 KB + }; + + // Store all source codes + for (const [snapId, snap] of Object.entries(snaps)) { + await service.setItem( + 'SnapController', + `${snapId}:sourceCode`, + snap.sourceCode, + ); + } + + // Verify all keys are tracked + const keys = await service.getAllKeys('SnapController'); + expect(keys).toHaveLength(5); + + // Retrieve specific snap source code + const response = await service.getItem( + 'SnapController', + 'npm:@metamask/bitcoin-wallet-snap:sourceCode', + ); + + expect(response).toStrictEqual({ + result: snaps['npm:@metamask/bitcoin-wallet-snap'].sourceCode, + }); + + // Clear all snap data + await service.clear('SnapController'); + const keysAfterClear = await service.getAllKeys('SnapController'); + + expect(keysAfterClear).toStrictEqual([]); + }); + + it('delegates getAllKeys to adapter', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn().mockResolvedValue({}), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest + .fn() + .mockResolvedValue(['snap1:sourceCode', 'snap2:sourceCode']), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + const keys = await service.getAllKeys('SnapController'); + + expect(mockStorage.getAllKeys).toHaveBeenCalledWith('SnapController'); + expect(keys).toStrictEqual(['snap1:sourceCode', 'snap2:sourceCode']); + }); + + it('adapter handles namespace filtering', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn().mockResolvedValue({}), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockImplementation((namespace) => { + // Adapter filters by namespace + if (namespace === 'TestController') { + return Promise.resolve(['key1', 'key2']); + } + return Promise.resolve([]); + }), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + await service.setItem('TestController', 'key1', 'value1'); + await service.setItem('TestController', 'key2', 'value2'); + + const keys = await service.getAllKeys('TestController'); + + expect(mockStorage.getAllKeys).toHaveBeenCalledWith('TestController'); + expect(keys).toStrictEqual(['key1', 'key2']); + }); + }); +}); + +/** + * The type of the messenger populated with all external actions and events + * required by the service under test. + */ +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * Constructs the messenger populated with all external actions and events + * required by the service under test. + * + * @returns The root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the service under test. + * + * @param rootMessenger - The root messenger, with all external actions and + * events required by the service's messenger. + * @returns The service-specific messenger. + */ +function getMessenger(rootMessenger: RootMessenger): StorageServiceMessenger { + return new Messenger({ + namespace: 'StorageService', + parent: rootMessenger, + }); +} + +/** + * Constructs the service under test. + * + * @param args - The arguments to this function. + * @param args.storage - Optional storage adapter to use. + * @returns The new service, root messenger, and service messenger. + */ +function getService({ + storage, +}: { + storage?: StorageAdapter; +} = {}): { + service: StorageService; + rootMessenger: RootMessenger; + messenger: StorageServiceMessenger; +} { + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const service = new StorageService({ + messenger, + storage, + }); + + return { service, rootMessenger, messenger }; +} diff --git a/packages/storage-service/src/StorageService.ts b/packages/storage-service/src/StorageService.ts new file mode 100644 index 00000000000..dfb5381e624 --- /dev/null +++ b/packages/storage-service/src/StorageService.ts @@ -0,0 +1,220 @@ +import type { Json } from '@metamask/utils'; + +import { InMemoryStorageAdapter } from './InMemoryStorageAdapter'; +import type { + StorageAdapter, + StorageGetResult, + StorageServiceMessenger, + StorageServiceOptions, +} from './types'; +import { SERVICE_NAME } from './types'; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'setItem', + 'getItem', + 'removeItem', + 'getAllKeys', + 'clear', +] as const; + +/** + * StorageService provides a platform-agnostic way for controllers to store + * large, infrequently accessed data outside of memory/Redux state. + * + * **Use cases:** + * - Snap source code (6+ MB that's rarely accessed) + * - Token metadata caches (4+ MB of cached data) + * - Large cached responses from APIs + * - Any data > 100 KB that's not frequently accessed + * + * **Benefits:** + * - Reduces memory usage (data stays on disk) + * - Faster Redux persist (less data to serialize) + * - Faster app startup (less data to parse) + * - Lazy loading (data loaded only when needed) + * + * **Platform Support:** + * - Mobile: FilesystemStorage adapter + * - Extension: IndexedDB adapter + * - Tests/Dev: InMemoryStorageAdapter (default) + * + * @example Using the service via messenger + * + * ```typescript + * // In a controller + * type AllowedActions = + * | StorageServiceSetItemAction + * | StorageServiceGetItemAction; + * + * class SnapController extends BaseController { + * async storeSnapSourceCode(snapId: string, sourceCode: string) { + * await this.messenger.call( + * 'StorageService:setItem', + * 'SnapController', + * `${snapId}:sourceCode`, + * sourceCode, + * ); + * } + * + * async getSnapSourceCode(snapId: string): Promise { + * const { result, error } = await this.messenger.call( + * 'StorageService:getItem', + * 'SnapController', + * `${snapId}:sourceCode`, + * ); + * if (error) { + * throw error; // Handle error + * } + * return result as string | undefined; // undefined if not found + * } + * } + * ``` + * + * @example Initializing in a client + * + * ```typescript + * // Mobile + * const service = new StorageService({ + * messenger: storageServiceMessenger, + * storage: filesystemStorageAdapter, // Platform-specific + * }); + * + * // Extension + * const service = new StorageService({ + * messenger: storageServiceMessenger, + * storage: indexedDBAdapter, // Platform-specific + * }); + * + * // Tests (uses in-memory by default) + * const service = new StorageService({ + * messenger: storageServiceMessenger, + * // No storage - uses InMemoryStorageAdapter + * }); + * ``` + */ +export class StorageService { + /** + * The name of the service. + */ + readonly name: typeof SERVICE_NAME; + + /** + * The messenger suited for this service. + */ + readonly #messenger: StorageServiceMessenger; + + /** + * The storage adapter for persisting data. + */ + readonly #storage: StorageAdapter; + + /** + * Constructs a new StorageService. + * + * @param options - The options. + * @param options.messenger - The messenger suited for this service. + * @param options.storage - Storage adapter for persisting data. + * If not provided, uses InMemoryStorageAdapter (data lost on restart). + */ + constructor({ messenger, storage }: StorageServiceOptions) { + this.name = SERVICE_NAME; + this.#messenger = messenger; + this.#storage = storage ?? new InMemoryStorageAdapter(); + + // Warn if using in-memory storage (data won't persist) + if (!storage) { + console.warn( + `${SERVICE_NAME}: No storage adapter provided. Using in-memory storage. ` + + 'Data will be lost on restart. Provide a storage adapter for persistence.', + ); + } + + // Register messenger actions + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Store large JSON data in storage. + * + * ⚠️ **Designed for large values (100KB+), not many small ones.** + * Each storage operation has I/O overhead. For best performance, + * store one large object rather than many small key-value pairs. + * + * @example Good: Store entire cache as one value + * ```typescript + * await service.setItem('TokenList', 'cache', { '0x1': [...], '0x38': [...] }); + * ``` + * + * @example Avoid: Many small values + * ```typescript + * // ❌ Don't do this - too many small writes + * await service.setItem('TokenList', 'cache:0x1', [...]); + * await service.setItem('TokenList', 'cache:0x38', [...]); + * ``` + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + * @param key - Storage key (e.g., 'npm:@metamask/example-snap:sourceCode'). + * @param value - JSON data to store (should be 100KB+ for optimal use). + */ + async setItem(namespace: string, key: string, value: Json): Promise { + // Adapter handles serialization and wrapping with metadata + await this.#storage.setItem(namespace, key, value); + + // Publish event so other controllers can react to changes + // Event type: StorageService:itemSet:namespace + // Payload: [key, value] + this.#messenger.publish( + `${SERVICE_NAME}:itemSet:${namespace}` as const, + key, + value, + ); + } + + /** + * Retrieve JSON data from storage. + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + * @param key - Storage key (e.g., 'npm:@metamask/example-snap:sourceCode'). + * @returns StorageGetResult: { result } if found, {} if not found, { error } on failure. + */ + async getItem(namespace: string, key: string): Promise { + // Adapter handles deserialization and unwrapping + return await this.#storage.getItem(namespace, key); + } + + /** + * Remove data from storage. + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + * @param key - Storage key (e.g., 'npm:@metamask/example-snap:sourceCode'). + */ + async removeItem(namespace: string, key: string): Promise { + // Adapter builds full storage key (e.g., mobile: 'storageService:namespace:key') + await this.#storage.removeItem(namespace, key); + } + + /** + * Get all keys for a namespace. + * Delegates to storage adapter which handles filtering. + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + * @returns Array of keys (without prefix) for this namespace. + */ + async getAllKeys(namespace: string): Promise { + return await this.#storage.getAllKeys(namespace); + } + + /** + * Clear all data for a namespace. + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + */ + async clear(namespace: string): Promise { + await this.#storage.clear(namespace); + } +} diff --git a/packages/storage-service/src/index.ts b/packages/storage-service/src/index.ts new file mode 100644 index 00000000000..d17a8f8fa5c --- /dev/null +++ b/packages/storage-service/src/index.ts @@ -0,0 +1,28 @@ +// Export service class +export { StorageService } from './StorageService'; + +// Export adapters +export { InMemoryStorageAdapter } from './InMemoryStorageAdapter'; + +// Export types from types.ts +export type { + StorageAdapter, + StorageGetResult, + StorageServiceOptions, + StorageServiceActions, + StorageServiceEvents, + StorageServiceMessenger, + StorageServiceItemSetEvent, +} from './types'; + +// Export individual action types from generated file +export type { + StorageServiceSetItemAction, + StorageServiceGetItemAction, + StorageServiceRemoveItemAction, + StorageServiceGetAllKeysAction, + StorageServiceClearAction, +} from './StorageService-method-action-types'; + +// Export service name and storage key prefix constants +export { SERVICE_NAME, STORAGE_KEY_PREFIX } from './types'; diff --git a/packages/storage-service/src/types.ts b/packages/storage-service/src/types.ts new file mode 100644 index 00000000000..4ecfc0da2bb --- /dev/null +++ b/packages/storage-service/src/types.ts @@ -0,0 +1,180 @@ +import type { Messenger } from '@metamask/messenger'; +import type { Json } from '@metamask/utils'; + +import type { StorageServiceMethodActions } from './StorageService-method-action-types'; + +/** + * Result type for getItem operations. + * Distinguishes between: found data, not found, and error conditions. + * + * - `{ result: Json }` - Data was found and successfully retrieved + * - `{}` (empty object) - No data stored with that key + * - `{ error: Error }` - Error occurred during retrieval + */ +export type StorageGetResult = + | { result: Json; error?: never } + | { result?: never; error: Error } + | Record; + +/** + * Platform-agnostic storage adapter interface. + * Each client (mobile, extension) implements this interface + * with their preferred storage mechanism. + * + * ⚠️ **Designed for large, infrequently accessed data (100KB+)** + * + * ✅ **Use for:** + * - Snap source code (~6 MB per snap) + * - Token metadata caches (~4 MB) + * - Large API response caches + * + * ❌ **Avoid for:** + * - Small values (< 10 KB) - use controller state instead + * - Frequently accessed data - use controller state instead + * - Many small key-value pairs - use a single large object instead + * + * @example Mobile implementation using FilesystemStorage + * @example Extension implementation using IndexedDB + * @example Tests using InMemoryStorageAdapter + */ +export type StorageAdapter = { + /** + * Retrieve an item from storage. + * Adapter is responsible for building the full storage key. + * + * @param namespace - The controller namespace (e.g., 'SnapController'). + * @param key - The data key (e.g., 'snap-id:sourceCode'). + * @returns StorageGetResult: { result } if found, {} if not found, { error } on failure. + */ + getItem(namespace: string, key: string): Promise; + + /** + * Store a large JSON value in storage. + * + * ⚠️ **Store large values, not many small ones.** + * Each storage operation has I/O overhead. For best performance: + * - Store one large object rather than many small key-value pairs + * - Minimum recommended size: 100 KB per value + * + * Adapter is responsible for: + * - Building the full storage key + * - Serializing to string (JSON.stringify) + * + * @param namespace - The controller namespace (e.g., 'SnapController'). + * @param key - The data key (e.g., 'snap-id:sourceCode'). + * @param value - The JSON value to store. + */ + setItem(namespace: string, key: string, value: Json): Promise; + + /** + * Remove an item from storage. + * Adapter is responsible for building the full storage key. + * + * @param namespace - The controller namespace (e.g., 'SnapController'). + * @param key - The data key (e.g., 'snap-id:sourceCode'). + */ + removeItem(namespace: string, key: string): Promise; + + /** + * Get all keys for a specific namespace. + * Should return keys without the 'storage:namespace:' prefix. + * + * Adapter is responsible for: + * - Filtering keys by prefix: 'storage:{namespace}:' + * - Stripping the prefix from returned keys + * - Returning only the key portion after the prefix + * + * @param namespace - The namespace to get keys for (e.g., 'SnapController'). + * @returns Array of keys without prefix (e.g., ['snap1:sourceCode', 'snap2:sourceCode']). + */ + getAllKeys(namespace: string): Promise; + + /** + * Clear all items for a specific namespace. + * + * Adapter is responsible for: + * - Finding all keys with prefix: 'storageService:{namespace}:' + * - Removing all matching keys + * + * @param namespace - The namespace to clear (e.g., 'SnapController'). + */ + clear(namespace: string): Promise; +}; + +/** + * Options for constructing a {@link StorageService}. + */ +export type StorageServiceOptions = { + /** + * The messenger suited for this service. + */ + messenger: StorageServiceMessenger; + + /** + * Storage adapter for persisting data. + * If not provided, uses in-memory storage (data lost on restart). + * Production clients MUST provide a persistent storage adapter. + */ + storage?: StorageAdapter; +}; + +// Service name constant +export const SERVICE_NAME = 'StorageService'; + +/** + * Storage key prefix for all keys managed by StorageService. + * Keys are formatted as: {STORAGE_KEY_PREFIX}{namespace}:{key} + * Example: 'storageService:SnapController:snap-id:sourceCode' + */ +export const STORAGE_KEY_PREFIX = 'storageService:'; + +/** + * All actions that {@link StorageService} exposes to other consumers. + * Action types are auto-generated from the service methods. + */ +export type StorageServiceActions = StorageServiceMethodActions; + +/** + * Event published when a storage item is set. + * Event type includes namespace only, key passed in payload. + * + * @example + * Subscribe to all changes in TokenListController: + * messenger.subscribe('StorageService:itemSet:TokenListController', (key, value) => { + * // key = 'cache:0x1', 'cache:0x38', etc. + * // value = the data that was set + * if (key.startsWith('cache:')) { + * const chainId = key.replace('cache:', ''); + * // React to cache change for specific chain + * } + * }); + */ +export type StorageServiceItemSetEvent = { + type: `${typeof SERVICE_NAME}:itemSet:${string}`; + payload: [key: string, value: Json]; +}; + +/** + * All events that {@link StorageService} publishes. + */ +export type StorageServiceEvents = StorageServiceItemSetEvent; + +/** + * Actions from other messengers that {@link StorageService} calls. + */ +type AllowedActions = never; + +/** + * Events from other messengers that {@link StorageService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events that + * {@link StorageService} needs to access. + */ +export type StorageServiceMessenger = Messenger< + typeof SERVICE_NAME, + StorageServiceActions | AllowedActions, + StorageServiceEvents | AllowedEvents +>; diff --git a/packages/storage-service/tsconfig.build.json b/packages/storage-service/tsconfig.build.json new file mode 100644 index 00000000000..57f3ffc0f9b --- /dev/null +++ b/packages/storage-service/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [{ "path": "../messenger/tsconfig.build.json" }], + "include": ["../../types", "./src"] +} diff --git a/packages/storage-service/tsconfig.json b/packages/storage-service/tsconfig.json new file mode 100644 index 00000000000..77e4d580465 --- /dev/null +++ b/packages/storage-service/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [{ "path": "../messenger" }], + "include": ["../../types", "./src"] +} diff --git a/packages/storage-service/typedoc.json b/packages/storage-service/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/storage-service/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 4f4e520dae6..563fd486da2 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/transaction-controller` from `^62.4.0` to `^62.5.0` ([#7325](https://github.com/MetaMask/core/pull/7325)) + +## [5.4.0] + +### Changed + +- Updated `GetSubscriptionsResponse` and controller state to include `rewardAccountId` property ([#7319](https://github.com/MetaMask/core/pull/7319)) + +## [5.3.1] + +### Changed + +- Renamed parameters related to rewards linking with shield. ([#7311](https://github.com/MetaMask/core/pull/7311)) + - Renamed from `rewardSubscriptionId` to `rewardAccountId`. + +## [5.3.0] + +### Added + +- Added new method, `linkRewards` to link rewards to the existing subscription. ([#7283](https://github.com/MetaMask/core/pull/7283)) +- Added an optional param, `rewardSubscriptionId` to start subscription requests to opt in to rewards together with the main subscription. ([#7283](https://github.com/MetaMask/core/pull/7283)) +- Added an option param, `rewardSubscriptionId` in `submitShieldSubscriptionCryptoApproval` to support rewards with crypto subscriptions. ([#7298](https://github.com/MetaMask/core/pull/7298)) +- Added `SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction` and `SubscriptionControllerLinkRewardsAction` to exports. ([#7298](https://github.com/MetaMask/core/pull/7298)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.3.1` to `^62.4.0` ([#7289](https://github.com/MetaMask/core/pull/7289)) + +## [5.2.0] + +### Added + +- Added `minBillingCyclesForBalance` property to `ProductPrice` type ([#7269](https://github.com/MetaMask/core/pull/7269)) +- Added `getTokenMinimumBalanceAmount` method to `SubscriptonController` ([#7269](https://github.com/MetaMask/core/pull/7269)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.3.0` to `^62.3.1` ([#7257](https://github.com/MetaMask/core/pull/7257)) + +## [5.1.0] + +### Changed + +- Removed `minBalanceUSD` field from the `SubscriptionEligibility` type. ([#7248](https://github.com/MetaMask/core/pull/7248)) +- Updated `submitShieldSubscriptionCryptoApproval` to handle change payment method transaction if subscription already existed ([#7231](https://github.com/MetaMask/core/pull/7231)) +- Bump `@metamask/transaction-controller` from `^62.0.0` to `^62.3.0` ([#7215](https://github.com/MetaMask/core/pull/7215), [#7220](https://github.com/MetaMask/core/pull/7220), [#7236](https://github.com/MetaMask/core/pull/7236)) +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/profile-sync-controller` (^27.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [5.0.0] ### Changed @@ -203,7 +258,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/controller-utils` from `^11.12.0` to `^11.14.0` ([#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629)) - Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@5.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@5.4.0...HEAD +[5.4.0]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@5.3.1...@metamask/subscription-controller@5.4.0 +[5.3.1]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@5.3.0...@metamask/subscription-controller@5.3.1 +[5.3.0]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@5.2.0...@metamask/subscription-controller@5.3.0 +[5.2.0]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@5.1.0...@metamask/subscription-controller@5.2.0 +[5.1.0]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@5.0.0...@metamask/subscription-controller@5.1.0 [5.0.0]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@4.2.2...@metamask/subscription-controller@5.0.0 [4.2.2]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@4.2.1...@metamask/subscription-controller@4.2.2 [4.2.1]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@4.2.0...@metamask/subscription-controller@4.2.1 diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index 372f296e785..6093e60d824 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/subscription-controller", - "version": "5.0.0", + "version": "5.4.0", "description": "Handle user subscription", "keywords": [ "MetaMask", @@ -52,13 +52,13 @@ "@metamask/controller-utils": "^11.16.0", "@metamask/messenger": "^0.3.0", "@metamask/polling-controller": "^16.0.0", - "@metamask/transaction-controller": "^62.0.0", + "@metamask/profile-sync-controller": "^27.0.0", + "@metamask/transaction-controller": "^62.5.0", "@metamask/utils": "^11.8.1", "bignumber.js": "^9.1.2" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/profile-sync-controller": "^27.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -69,9 +69,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/profile-sync-controller": "^27.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index eac28dde529..3cbcb82f563 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -1,10 +1,9 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { TransactionStatus, @@ -21,10 +20,12 @@ import { SubscriptionServiceError } from './errors'; import { getDefaultSubscriptionControllerState, SubscriptionController, - type AllowedEvents, - type SubscriptionControllerMessenger, - type SubscriptionControllerOptions, - type SubscriptionControllerState, +} from './SubscriptionController'; +import type { + AllowedEvents, + SubscriptionControllerMessenger, + SubscriptionControllerOptions, + SubscriptionControllerState, } from './SubscriptionController'; import type { Subscription, @@ -94,6 +95,7 @@ const MOCK_PRODUCT_PRICE: ProductPricing = { unitDecimals: 2, trialPeriodDays: 0, minBillingCycles: 12, + minBillingCyclesForBalance: 1, }, { interval: 'year', @@ -102,6 +104,7 @@ const MOCK_PRODUCT_PRICE: ProductPricing = { currency: 'usd', trialPeriodDays: 14, minBillingCycles: 1, + minBillingCyclesForBalance: 1, }, ], }; @@ -236,6 +239,7 @@ function createMockSubscriptionService() { const mockSubmitUserEvent = jest.fn(); const mockSubmitSponsorshipIntents = jest.fn(); const mockAssignUserToCohort = jest.fn(); + const mockLinkRewards = jest.fn(); const mockService = { getSubscriptions: mockGetSubscriptions, @@ -251,6 +255,7 @@ function createMockSubscriptionService() { submitUserEvent: mockSubmitUserEvent, submitSponsorshipIntents: mockSubmitSponsorshipIntents, assignUserToCohort: mockAssignUserToCohort, + linkRewards: mockLinkRewards, }; return { @@ -589,6 +594,66 @@ describe('SubscriptionController', () => { }, ); }); + + it('should update state when rewardAccountId changes from undefined to defined', async () => { + await withController( + { + state: { + rewardAccountId: undefined, + }, + }, + async ({ controller, mockService }) => { + mockService.getSubscriptions.mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [], + trialedProducts: [], + rewardAccountId: + 'eip155:1:0x1234567890123456789012345678901234567890', + }); + + await controller.getSubscriptions(); + + expect(controller.state.rewardAccountId).toBe( + 'eip155:1:0x1234567890123456789012345678901234567890', + ); + }, + ); + }); + + it('should not update state when rewardAccountId is the same', async () => { + const mockRewardAccountId = + 'eip155:1:0x1234567890123456789012345678901234567890'; + + await withController( + { + state: { + customerId: 'cus_1', + subscriptions: [], + trialedProducts: [], + rewardAccountId: mockRewardAccountId, + }, + }, + async ({ controller, mockService, rootMessenger }) => { + mockService.getSubscriptions.mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [], + trialedProducts: [], + rewardAccountId: mockRewardAccountId, + }); + + const stateChangeListener = jest.fn(); + rootMessenger.subscribe( + 'SubscriptionController:stateChange', + stateChangeListener, + ); + + await controller.getSubscriptions(); + + // State should not have changed since rewardAccountId is the same + expect(stateChangeListener).not.toHaveBeenCalled(); + }, + ); + }); }); describe('getSubscriptionByProduct', () => { @@ -1114,6 +1179,7 @@ describe('SubscriptionController', () => { unitDecimals: 18, trialPeriodDays: 0, minBillingCycles: 1, + minBillingCyclesForBalance: 1, }, ], }, @@ -1258,6 +1324,45 @@ describe('SubscriptionController', () => { }); }); + describe('getTokenMinimumBalanceAmount', () => { + it('returns correct minimum balance amount for token', async () => { + await withController(async ({ controller }) => { + const [price] = MOCK_PRODUCT_PRICE.prices; + const { chains } = MOCK_PRICING_PAYMENT_METHOD; + if (!chains || chains.length === 0) { + throw new Error('Mock chains not found'); + } + const [tokenPaymentInfo] = chains[0].tokens; + + const result = controller.getTokenMinimumBalanceAmount( + price, + tokenPaymentInfo, + ); + + expect(result).toBe('9000000000000000000'); + }); + }); + + it('throws when conversion rate not found', async () => { + await withController(async ({ controller }) => { + const price = MOCK_PRODUCT_PRICE.prices[0]; + const tokenPaymentInfoWithoutRate = { + address: '0xtoken' as const, + decimals: 18, + symbol: 'USDT', + conversionRate: {} as { usd: string }, + }; + + expect(() => + controller.getTokenMinimumBalanceAmount( + price, + tokenPaymentInfoWithoutRate, + ), + ).toThrow('Conversion rate not found'); + }); + }); + }); + describe('triggerAuthTokenRefresh', () => { it('should trigger auth token refresh', async () => { await withController(async ({ controller, mockPerformSignOut }) => { @@ -1425,7 +1530,6 @@ describe('SubscriptionController', () => { const MOCK_SUBSCRIPTION_ELIGIBILITY: SubscriptionEligibility = { product: PRODUCT_TYPES.SHIELD, canSubscribe: true, - minBalanceUSD: 100, canViewEntryModal: true, modalType: MODAL_TYPE.A, cohorts: [], @@ -1891,9 +1995,12 @@ describe('SubscriptionController', () => { status: SUBSCRIPTION_STATUSES.trialing, }); - mockService.getSubscriptions.mockResolvedValue( - MOCK_GET_SUBSCRIPTIONS_RESPONSE, - ); + mockService.getSubscriptions + .mockResolvedValueOnce({ + subscriptions: [], + trialedProducts: [], + }) + .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); // Create a shield subscription approval transaction const txMeta = { @@ -1918,6 +2025,74 @@ describe('SubscriptionController', () => { ); }); + it('should handle subscription crypto approval when shield subscription transaction is submitted with reward subscription ID', async () => { + await withController( + { + state: { + pricing: MOCK_PRICE_INFO_RESPONSE, + trialedProducts: [], + subscriptions: [], + lastSelectedPaymentMethod: { + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xtoken', + paymentTokenSymbol: 'USDT', + plan: RECURRING_INTERVALS.month, + }, + }, + }, + }, + async ({ controller, mockService }) => { + mockService.startSubscriptionWithCrypto.mockResolvedValue({ + subscriptionId: 'sub_123', + status: SUBSCRIPTION_STATUSES.trialing, + }); + + mockService.getSubscriptions + .mockResolvedValueOnce({ + subscriptions: [], + trialedProducts: [], + }) + .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); + + // Create a shield subscription approval transaction + const txMeta = { + ...generateMockTxMeta(), + type: TransactionType.shieldSubscriptionApprove, + chainId: '0x1' as Hex, + rawTx: '0x123', + txParams: { + data: '0x456', + from: '0x1234567890123456789012345678901234567890', + to: '0xtoken', + }, + status: TransactionStatus.submitted, + }; + + await controller.submitShieldSubscriptionCryptoApproval( + txMeta, + false, // isSponsored + 'eip155:1:0x1234567890123456789012345678901234567890', + ); + + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith({ + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: true, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 12, + chainId: '0x1', + payerAddress: '0x1234567890123456789012345678901234567890', + tokenSymbol: 'USDT', + rawTransaction: '0x123', + isSponsored: false, + useTestClock: undefined, + rewardAccountId: + 'eip155:1:0x1234567890123456789012345678901234567890', + }); + }, + ); + }); + it('should not handle subscription crypto approval when pricing is not found', async () => { await withController( { @@ -2095,5 +2270,168 @@ describe('SubscriptionController', () => { }, ); }); + + it('should update payment method when user has active subscription', async () => { + await withController( + { + state: { + pricing: MOCK_PRICE_INFO_RESPONSE, + trialedProducts: [], + subscriptions: [MOCK_SUBSCRIPTION], + lastSelectedPaymentMethod: { + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xtoken', + paymentTokenSymbol: 'USDT', + plan: RECURRING_INTERVALS.month, + }, + }, + }, + }, + async ({ controller, mockService }) => { + mockService.updatePaymentMethodCrypto.mockResolvedValue({}); + mockService.getSubscriptions.mockResolvedValue( + MOCK_GET_SUBSCRIPTIONS_RESPONSE, + ); + + const txMeta = { + ...generateMockTxMeta(), + type: TransactionType.shieldSubscriptionApprove, + chainId: '0x1' as Hex, + rawTx: '0x123', + txParams: { + data: '0x456', + from: '0x1234567890123456789012345678901234567890', + to: '0xtoken', + }, + status: TransactionStatus.submitted, + }; + + await controller.submitShieldSubscriptionCryptoApproval(txMeta); + + expect(mockService.updatePaymentMethodCrypto).toHaveBeenCalledTimes( + 1, + ); + expect( + mockService.startSubscriptionWithCrypto, + ).not.toHaveBeenCalled(); + }, + ); + }); + + it('should throw error when subscription status is not valid for crypto approval', async () => { + await withController( + { + state: { + pricing: MOCK_PRICE_INFO_RESPONSE, + trialedProducts: [], + subscriptions: [], + lastSelectedPaymentMethod: { + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xtoken', + paymentTokenSymbol: 'USDT', + plan: RECURRING_INTERVALS.month, + }, + }, + }, + }, + async ({ controller, mockService }) => { + mockService.getSubscriptions.mockResolvedValue({ + subscriptions: [ + { + ...MOCK_SUBSCRIPTION, + status: SUBSCRIPTION_STATUSES.incomplete, + }, + ], + trialedProducts: [], + }); + + const txMeta = { + ...generateMockTxMeta(), + type: TransactionType.shieldSubscriptionApprove, + chainId: '0x1' as Hex, + rawTx: '0x123', + txParams: { + data: '0x456', + from: '0x1234567890123456789012345678901234567890', + to: '0xtoken', + }, + status: TransactionStatus.submitted, + }; + + await expect( + controller.submitShieldSubscriptionCryptoApproval(txMeta), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.SubscriptionNotValidForCryptoApproval, + ); + }, + ); + }); + }); + + describe('linkRewards', () => { + it('should link rewards successfully', async () => { + await withController( + { + state: { + subscriptions: [MOCK_SUBSCRIPTION], + }, + }, + async ({ controller, mockService }) => { + const linkRewardsSpy = jest + .spyOn(mockService, 'linkRewards') + .mockResolvedValue({ + success: true, + }); + await controller.linkRewards({ + subscriptionId: 'sub_123456789', + rewardAccountId: + 'eip155:1:0x1234567890123456789012345678901234567890', + }); + expect(linkRewardsSpy).toHaveBeenCalledWith({ + rewardAccountId: + 'eip155:1:0x1234567890123456789012345678901234567890', + }); + expect(linkRewardsSpy).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('should throw error when user is not subscribed', async () => { + await withController(async ({ controller }) => { + await expect( + controller.linkRewards({ + subscriptionId: 'sub_123456789', + rewardAccountId: + 'eip155:1:0x1234567890123456789012345678901234567890', + }), + ).rejects.toThrow(SubscriptionControllerErrorMessage.UserNotSubscribed); + }); + }); + + it('should throw error when link rewards fails', async () => { + await withController( + { + state: { + subscriptions: [MOCK_SUBSCRIPTION], + }, + }, + async ({ controller, mockService }) => { + mockService.linkRewards.mockResolvedValue({ + success: false, + }); + await expect( + controller.linkRewards({ + subscriptionId: 'sub_123456789', + rewardAccountId: + 'eip155:1:0x1234567890123456789012345678901234567890', + }), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.LinkRewardsFailed, + ); + }, + ); + }); }); }); diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index 6a654d39fed..63e9b6ae21b 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -1,14 +1,14 @@ -import { - type StateMetadata, - type ControllerStateChangeEvent, - type ControllerGetStateAction, +import type { + StateMetadata, + ControllerStateChangeEvent, + ControllerGetStateAction, } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import { TransactionType } from '@metamask/transaction-controller'; -import { type Hex } from '@metamask/utils'; +import type { CaipAccountId, Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { @@ -17,6 +17,7 @@ import { DEFAULT_POLLING_INTERVAL, SubscriptionControllerErrorMessage, } from './constants'; +import { PAYMENT_TYPES, PRODUCT_TYPES, SUBSCRIPTION_STATUSES } from './types'; import type { AssignCohortRequest, BillingPortalResponse, @@ -33,15 +34,15 @@ import type { CachedLastSelectedPaymentMethod, SubmitSponsorshipIntentsMethodParams, RecurringInterval, + SubscriptionStatus, + LinkRewardsRequest, } from './types'; -import { - PAYMENT_TYPES, - PRODUCT_TYPES, - type ISubscriptionService, - type PricingResponse, - type ProductType, - type StartSubscriptionRequest, - type Subscription, +import type { + ISubscriptionService, + PricingResponse, + ProductType, + StartSubscriptionRequest, + Subscription, } from './types'; export type SubscriptionControllerState = { @@ -51,6 +52,8 @@ export type SubscriptionControllerState = { pricing?: PricingResponse; /** The last subscription that user has subscribed to if any. */ lastSubscription?: Subscription; + /** The reward account ID if user has linked rewards to the subscription. */ + rewardAccountId?: CaipAccountId; /** * The last selected payment method for the user. * This is used to display the last selected payment method in the UI. @@ -105,6 +108,11 @@ export type SubscriptionControllerSubmitSponsorshipIntentsAction = { handler: SubscriptionController['submitSponsorshipIntents']; }; +export type SubscriptionControllerLinkRewardsAction = { + type: `${typeof controllerName}:linkRewards`; + handler: SubscriptionController['linkRewards']; +}; + export type SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction = { type: `${typeof controllerName}:submitShieldSubscriptionCryptoApproval`; @@ -127,7 +135,8 @@ export type SubscriptionControllerActions = | SubscriptionControllerUpdatePaymentMethodAction | SubscriptionControllerGetBillingPortalUrlAction | SubscriptionControllerSubmitSponsorshipIntentsAction - | SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction; + | SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction + | SubscriptionControllerLinkRewardsAction; export type AllowedActions = | AuthenticationController.AuthenticationControllerGetBearerToken @@ -214,6 +223,12 @@ const subscriptionControllerMetadata: StateMetadata includeInDebugSnapshot: false, usedInUi: true, }, + rewardAccountId: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, trialedProducts: { includeInStateLogs: true, persist: true, @@ -330,6 +345,11 @@ export class SubscriptionController extends StaticIntervalPollingController()< `${controllerName}:submitShieldSubscriptionCryptoApproval`, this.submitShieldSubscriptionCryptoApproval.bind(this), ); + + this.messenger.registerActionHandler( + `${controllerName}:linkRewards`, + this.linkRewards.bind(this), + ); } /** @@ -350,11 +370,14 @@ export class SubscriptionController extends StaticIntervalPollingController()< const currentTrialedProducts = this.state.trialedProducts; const currentCustomerId = this.state.customerId; const currentLastSubscription = this.state.lastSubscription; + const currentRewardAccountId = this.state.rewardAccountId; + const { customerId: newCustomerId, subscriptions: newSubscriptions, trialedProducts: newTrialedProducts, lastSubscription: newLastSubscription, + rewardAccountId: newRewardAccountId, } = await this.#subscriptionService.getSubscriptions(); // check if the new subscriptions are different from the current subscriptions @@ -374,20 +397,23 @@ export class SubscriptionController extends StaticIntervalPollingController()< ); const areCustomerIdsEqual = currentCustomerId === newCustomerId; - + const areRewardAccountIdsEqual = + currentRewardAccountId === newRewardAccountId; // only update the state if the subscriptions or trialed products are different // this prevents unnecessary state updates events, easier for the clients to handle if ( !areSubscriptionsEqual || !isLastSubscriptionEqual || !areTrialedProductsEqual || - !areCustomerIdsEqual + !areCustomerIdsEqual || + !areRewardAccountIdsEqual ) { this.update((state) => { state.subscriptions = newSubscriptions; state.customerId = newCustomerId; state.trialedProducts = newTrialedProducts; state.lastSubscription = newLastSubscription; + state.rewardAccountId = newRewardAccountId; }); // trigger access token refresh to ensure the user has the latest access token if subscription state change this.triggerAccessTokenRefresh(); @@ -483,11 +509,13 @@ export class SubscriptionController extends StaticIntervalPollingController()< * * @param txMeta - The transaction metadata. * @param isSponsored - Whether the transaction is sponsored. + * @param rewardAccountId - The account ID of the reward subscription to link to the shield subscription. * @returns void */ async submitShieldSubscriptionCryptoApproval( txMeta: TransactionMeta, isSponsored?: boolean, + rewardAccountId?: CaipAccountId, ) { if (txMeta.type !== TransactionType.shieldSubscriptionApprove) { return; @@ -509,26 +537,51 @@ export class SubscriptionController extends StaticIntervalPollingController()< lastSelectedPaymentMethod[PRODUCT_TYPES.SHIELD]; this.#assertIsPaymentMethodCrypto(lastSelectedPaymentMethodShield); - const isTrialed = trialedProducts?.includes(PRODUCT_TYPES.SHIELD); - const productPrice = this.#getProductPriceByProductAndPlan( PRODUCT_TYPES.SHIELD, lastSelectedPaymentMethodShield.plan, ); + const isTrialed = trialedProducts?.includes(PRODUCT_TYPES.SHIELD); + // get the latest subscriptions state to check if the user has an active shield subscription + await this.getSubscriptions(); + const currentSubscription = this.state.subscriptions.find((subscription) => + subscription.products.some((p) => p.name === PRODUCT_TYPES.SHIELD), + ); + + this.#assertValidSubscriptionStateForCryptoApproval({ + product: PRODUCT_TYPES.SHIELD, + }); + // if shield subscription exists, this transaction is for changing payment method + const isChangePaymentMethod = Boolean(currentSubscription); + + if (isChangePaymentMethod) { + await this.updatePaymentMethod({ + paymentType: PAYMENT_TYPES.byCrypto, + subscriptionId: (currentSubscription as Subscription).id, + chainId, + payerAddress: txMeta.txParams.from as Hex, + tokenSymbol: lastSelectedPaymentMethodShield.paymentTokenSymbol, + rawTransaction: rawTx as Hex, + recurringInterval: productPrice.interval, + billingCycles: productPrice.minBillingCycles, + }); + } else { + const params = { + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: !isTrialed, + recurringInterval: productPrice.interval, + billingCycles: productPrice.minBillingCycles, + chainId, + payerAddress: txMeta.txParams.from as Hex, + tokenSymbol: lastSelectedPaymentMethodShield.paymentTokenSymbol, + rawTransaction: rawTx as Hex, + isSponsored, + useTestClock: lastSelectedPaymentMethodShield.useTestClock, + rewardAccountId, + }; + await this.startSubscriptionWithCrypto(params); + } - const params = { - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: !isTrialed, - recurringInterval: productPrice.interval, - billingCycles: productPrice.minBillingCycles, - chainId, - payerAddress: txMeta.txParams.from as Hex, - tokenSymbol: lastSelectedPaymentMethodShield.paymentTokenSymbol, - rawTransaction: rawTx as Hex, - isSponsored, - useTestClock: lastSelectedPaymentMethodShield.useTestClock, - }; - await this.startSubscriptionWithCrypto(params); // update the subscriptions state after subscription created in server await this.getSubscriptions(); } @@ -726,12 +779,36 @@ export class SubscriptionController extends StaticIntervalPollingController()< await this.#subscriptionService.assignUserToCohort(request); } + /** + * Link rewards to a subscription. + * + * @param request - Request object containing the reward subscription ID. + * @param request.subscriptionId - The ID of the subscription to link rewards to. + * @param request.rewardAccountId - The account ID of the reward subscription to link to the subscription. + * @example { subscriptionId: '1234567890', rewardAccountId: 'eip155:1:0x1234567890123456789012345678901234567890' } + * @returns Resolves when the rewards are linked successfully. + */ + async linkRewards( + request: LinkRewardsRequest & { subscriptionId: string }, + ): Promise { + // assert that the user is subscribed to the subscription + this.#assertIsUserSubscribed({ subscriptionId: request.subscriptionId }); + + // link rewards to the subscription + const response = await this.#subscriptionService.linkRewards({ + rewardAccountId: request.rewardAccountId, + }); + if (!response.success) { + throw new Error(SubscriptionControllerErrorMessage.LinkRewardsFailed); + } + } + async _executePoll(): Promise { await this.getSubscriptions(); } /** - * Calculate total subscription price amount from price info + * Calculate total subscription price amount (approval amount) from price info * e.g: $8 per month * 12 months min billing cycles = $96 * * @param price - The price info @@ -746,6 +823,21 @@ export class SubscriptionController extends StaticIntervalPollingController()< return amount; } + /** + * Calculate minimum subscription balance amount from price info + * + * @param price - The price info + * @returns The balance amount + */ + #getSubscriptionBalanceAmount(price: ProductPrice) { + // no need to use BigInt since max unitDecimals are always 2 for price + const amount = new BigNumber(price.unitAmount) + .div(10 ** price.unitDecimals) + .multipliedBy(price.minBillingCyclesForBalance) + .toString(); + return amount; + } + /** * Calculate token approve amount from price info * @@ -774,6 +866,35 @@ export class SubscriptionController extends StaticIntervalPollingController()< return tokenAmount.toFixed(0); } + /** + * Calculate token minimum balance amount from price info + * + * @param price - The price info + * @param tokenPaymentInfo - The token price info + * @returns The token balance amount + */ + getTokenMinimumBalanceAmount( + price: ProductPrice, + tokenPaymentInfo: TokenPaymentInfo, + ): string { + const conversionRate = + tokenPaymentInfo.conversionRate[ + price.currency as keyof typeof tokenPaymentInfo.conversionRate + ]; + if (!conversionRate) { + throw new Error('Conversion rate not found'); + } + const balanceAmount = new BigNumber( + this.#getSubscriptionBalanceAmount(price), + ); + + const tokenDecimal = new BigNumber(10).pow(tokenPaymentInfo.decimals); + const tokenAmount = balanceAmount + .multipliedBy(tokenDecimal) + .div(conversionRate); + return tokenAmount.toFixed(0); + } + /** * Triggers an access token refresh. */ @@ -799,6 +920,34 @@ export class SubscriptionController extends StaticIntervalPollingController()< return productPrice; } + #assertValidSubscriptionStateForCryptoApproval({ + product, + }: { + product: ProductType; + }) { + const subscription = this.state.subscriptions.find((sub) => + sub.products.some((p) => p.name === product), + ); + + const isValid = + !subscription || + ( + [ + SUBSCRIPTION_STATUSES.pastDue, + SUBSCRIPTION_STATUSES.unpaid, + SUBSCRIPTION_STATUSES.paused, + SUBSCRIPTION_STATUSES.provisional, + SUBSCRIPTION_STATUSES.active, + SUBSCRIPTION_STATUSES.trialing, + ] as SubscriptionStatus[] + ).includes(subscription.status); + if (!isValid) { + throw new Error( + SubscriptionControllerErrorMessage.SubscriptionNotValidForCryptoApproval, + ); + } + } + #assertIsUserNotSubscribed({ products }: { products: ProductType[] }) { const subscription = this.state.subscriptions.find((sub) => sub.products.some((p) => products.includes(p.name)), diff --git a/packages/subscription-controller/src/SubscriptionService.test.ts b/packages/subscription-controller/src/SubscriptionService.test.ts index 82c7faf29db..e197b2bce63 100644 --- a/packages/subscription-controller/src/SubscriptionService.test.ts +++ b/packages/subscription-controller/src/SubscriptionService.test.ts @@ -98,7 +98,6 @@ function createMockEligibilityResponse(overrides = {}) { return { product: PRODUCT_TYPES.SHIELD, canSubscribe: true, - minBalanceUSD: 100, canViewEntryModal: true, cohorts: [], assignedCohort: null, @@ -474,7 +473,6 @@ describe('SubscriptionService', () => { handleFetchMock.mockResolvedValue([ { product: PRODUCT_TYPES.SHIELD, - minBalanceUSD: 100, }, ]); @@ -639,4 +637,29 @@ describe('SubscriptionService', () => { }); }); }); + + describe('linkRewards', () => { + it('should link rewards successfully', async () => { + await withMockSubscriptionService(async ({ service, config }) => { + handleFetchMock.mockResolvedValue({}); + + await service.linkRewards({ + rewardAccountId: + 'eip155:1:0x1234567890123456789012345678901234567890', + }); + + expect(handleFetchMock).toHaveBeenCalledWith( + SUBSCRIPTION_URL(config.env, 'rewards/link'), + { + method: 'POST', + headers: MOCK_HEADERS, + body: JSON.stringify({ + rewardAccountId: + 'eip155:1:0x1234567890123456789012345678901234567890', + }), + }, + ); + }); + }); + }); }); diff --git a/packages/subscription-controller/src/SubscriptionService.ts b/packages/subscription-controller/src/SubscriptionService.ts index f6122ce2501..d113271678e 100644 --- a/packages/subscription-controller/src/SubscriptionService.ts +++ b/packages/subscription-controller/src/SubscriptionService.ts @@ -1,10 +1,7 @@ import { handleFetch } from '@metamask/controller-utils'; -import { - getEnvUrls, - SubscriptionControllerErrorMessage, - type Env, -} from './constants'; +import { getEnvUrls, SubscriptionControllerErrorMessage } from './constants'; +import type { Env } from './constants'; import { SubscriptionServiceError } from './errors'; import type { AssignCohortRequest, @@ -25,6 +22,8 @@ import type { UpdatePaymentMethodCardResponse, UpdatePaymentMethodCryptoRequest, SubmitSponsorshipIntentsRequest, + LinkRewardsRequest, + SubscriptionApiGeneralResponse, } from './types'; export type SubscriptionServiceConfig = { @@ -175,6 +174,24 @@ export class SubscriptionService implements ISubscriptionService { await this.#makeRequest(path, 'POST', request); } + /** + * Link rewards to a subscription. + * + * @param request - Request object containing the reward account ID. + * @example { rewardAccountId: 'eip155:1:0x1234567890123456789012345678901234567890' } + * @returns The response from the API. + */ + async linkRewards( + request: LinkRewardsRequest, + ): Promise { + const path = 'rewards/link'; + return await this.#makeRequest( + path, + 'POST', + request, + ); + } + async #makeRequest( path: string, method: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH' = 'GET', diff --git a/packages/subscription-controller/src/constants.ts b/packages/subscription-controller/src/constants.ts index d5bdc8ed0f7..e3a5a2c1fa4 100644 --- a/packages/subscription-controller/src/constants.ts +++ b/packages/subscription-controller/src/constants.ts @@ -45,6 +45,8 @@ export enum SubscriptionControllerErrorMessage { PaymentTokenAddressAndSymbolRequiredForCrypto = `${controllerName} - Payment token address and symbol are required for crypto payment`, PaymentMethodNotCrypto = `${controllerName} - Payment method is not crypto`, ProductPriceNotFound = `${controllerName} - Product price not found`, + SubscriptionNotValidForCryptoApproval = `${controllerName} - Subscription is not valid for crypto approval`, + LinkRewardsFailed = `${controllerName} - Failed to link rewards`, } export const DEFAULT_POLLING_INTERVAL = 5 * 60 * 1_000; // 5 minutes diff --git a/packages/subscription-controller/src/index.ts b/packages/subscription-controller/src/index.ts index 7f7293154cc..5e54c51454f 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -16,6 +16,8 @@ export type { SubscriptionControllerOptions, SubscriptionControllerStateChangeEvent, SubscriptionControllerSubmitSponsorshipIntentsAction, + SubscriptionControllerLinkRewardsAction, + SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction, AllowedActions, AllowedEvents, } from './SubscriptionController'; diff --git a/packages/subscription-controller/src/types.ts b/packages/subscription-controller/src/types.ts index cece01cf0bd..9fe293c3e98 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -1,4 +1,4 @@ -import type { Hex } from '@metamask/utils'; +import type { CaipAccountId, Hex } from '@metamask/utils'; export const PRODUCT_TYPES = { SHIELD: 'shield', @@ -125,6 +125,8 @@ export type GetSubscriptionsResponse = { trialedProducts: ProductType[]; /** The last subscription that user has subscribed to if any. */ lastSubscription?: Subscription; + /** The reward account ID if user has linked rewards to the subscription. */ + rewardAccountId?: CaipAccountId; }; export type StartSubscriptionRequest = { @@ -133,6 +135,16 @@ export type StartSubscriptionRequest = { recurringInterval: RecurringInterval; successUrl?: string; useTestClock?: boolean; + + /** + * The optional ID of the reward subscription to be opt in along with the main `shield` subscription. + * This is required if user wants to opt in to the reward subscription during the `shield` subscription creation. + * + * @example { + * rewardAccountId: 'eip155:1:0x1234567890123456789012345678901234567890', + * } + */ + rewardAccountId?: CaipAccountId; }; export type StartSubscriptionResponse = { @@ -153,6 +165,15 @@ export type StartCryptoSubscriptionRequest = { rawTransaction: Hex; isSponsored?: boolean; useTestClock?: boolean; + /** + * The optional ID of the reward subscription to be opt in along with the main `shield` subscription. + * This is required if user wants to opt in to the reward subscription during the `shield` subscription creation. + * + * @example { + * rewardAccountId: 'eip155:1:0x1234567890123456789012345678901234567890', + * } + */ + rewardAccountId?: CaipAccountId; }; export type StartCryptoSubscriptionResponse = { @@ -160,6 +181,21 @@ export type StartCryptoSubscriptionResponse = { status: SubscriptionStatus; }; +/** + * General response type for the subscription API requests + * which doesn't require any specific response data. + */ +export type SubscriptionApiGeneralResponse = { + /** + * Whether the request was successful. + */ + success: boolean; + /** + * The message of the response. + */ + message?: string; +}; + export type AuthUtils = { getAccessToken: () => Promise; }; @@ -171,7 +207,10 @@ export type ProductPrice = { /** only usd for now */ currency: Currency; trialPeriodDays: number; + /** min billing cycles for approval */ minBillingCycles: number; + /** min billing cycles for account balance check */ + minBillingCyclesForBalance: number; }; export type ProductPricing = { @@ -273,7 +312,6 @@ export type Cohort = { export type SubscriptionEligibility = { product: ProductType; canSubscribe: boolean; - minBalanceUSD: number; canViewEntryModal: boolean; modalType?: ModalType; cohorts: Cohort[]; @@ -347,6 +385,13 @@ export type ISubscriptionService = { submitUserEvent(request: SubmitUserEventRequest): Promise; assignUserToCohort(request: AssignCohortRequest): Promise; + /** + * Link rewards to a subscription. + */ + linkRewards( + request: LinkRewardsRequest, + ): Promise; + /** * Submit sponsorship intents to the Subscription Service backend. * @@ -420,3 +465,17 @@ export type CachedLastSelectedPaymentMethod = { plan: RecurringInterval; useTestClock?: boolean; }; + +/** + * Request object for linking rewards to a subscription. + */ +export type LinkRewardsRequest = { + /** + * The ID of the reward subscription to be linked to the subscription. + * + * @example { + * rewardAccountId: 'eip155:1:0x1234567890123456789012345678901234567890', + * } + */ + rewardAccountId: CaipAccountId; +}; diff --git a/packages/token-search-discovery-controller/src/token-search-discovery-controller.test.ts b/packages/token-search-discovery-controller/src/token-search-discovery-controller.test.ts index 7efc2bca220..5622a609720 100644 --- a/packages/token-search-discovery-controller/src/token-search-discovery-controller.test.ts +++ b/packages/token-search-discovery-controller/src/token-search-discovery-controller.test.ts @@ -1,10 +1,9 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - Messenger, - MOCK_ANY_NAMESPACE, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, } from '@metamask/messenger'; import { AbstractTokenDiscoveryApiService } from './token-discovery-api-service/abstract-token-discovery-api-service'; diff --git a/packages/token-search-discovery-controller/src/token-search-discovery-controller.ts b/packages/token-search-discovery-controller/src/token-search-discovery-controller.ts index 872c4705879..9a4dc79cbe9 100644 --- a/packages/token-search-discovery-controller/src/token-search-discovery-controller.ts +++ b/packages/token-search-discovery-controller/src/token-search-discovery-controller.ts @@ -1,8 +1,8 @@ -import { - BaseController, - type ControllerGetStateAction, - type ControllerStateChangeEvent, - type StateMetadata, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 0760207cb1f..02489c315ae 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [62.5.0] + +### Changed + +- Use gas fee properties from first transaction in EIP-7702 transactions ([#7323](https://github.com/MetaMask/core/pull/7323)) +- Bump `@metamask/remote-feature-flag-controller` from `^2.0.1` to `^3.0.0` ([#7309](https://github.com/MetaMask/core/pull/7309) + +## [62.4.0] + +### Added + +- Add `overwriteUpgrade` option to `TransactionBatchRequest` to allow overwriting existing EIP-7702 delegations ([#7282](https://github.com/MetaMask/core/pull/7282)) + +### Changed + +- Bump `@metamask/network-controller` from `^26.0.0` to `^27.0.0` ([#7258](https://github.com/MetaMask/core/pull/7258)) + +## [62.3.1] + +### Fixed + +- Fail required transactions of any approved and signed transactions during initialisation ([#7251](https://github.com/MetaMask/core/pull/7251)) + - Include `isExternalSign` when fetching gas fee tokens in messenger action or before publish check. + +## [62.3.0] + +### Changed + +- Check balance and gas fee tokens only after before sign hook ([#7234](https://github.com/MetaMask/core/pull/7234)) + +## [62.2.0] + +### Added + +- Add `musdConversion` transaction type ([#7218](https://github.com/MetaMask/core/pull/7218)) + +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/accounts-controller` (^35.0.0) + - `@metamask/approval-controller` (^8.0.0) + - `@metamask/gas-fee-controller` (^26.0.0) + - `@metamask/network-controller` (^26.0.0) + - `@metamask/remote-feature-flag-controller` (^2.0.1) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + +## [62.1.0] + +### Changed + +- Performance optimisations in `addTransaction` and `addTransactionBatch` methods ([#7205](https://github.com/MetaMask/core/pull/7205)) + - Add `skipInitialGasEstimate` option to `addTransaction` and `addTransactionBatch` methods. + - Add `disableUpgrade` option to `addTransactionBatch` method. + ## [62.0.0] ### Added @@ -1952,7 +2009,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@62.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@62.5.0...HEAD +[62.5.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@62.4.0...@metamask/transaction-controller@62.5.0 +[62.4.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@62.3.1...@metamask/transaction-controller@62.4.0 +[62.3.1]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@62.3.0...@metamask/transaction-controller@62.3.1 +[62.3.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@62.2.0...@metamask/transaction-controller@62.3.0 +[62.2.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@62.1.0...@metamask/transaction-controller@62.2.0 +[62.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@62.0.0...@metamask/transaction-controller@62.1.0 [62.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@61.3.0...@metamask/transaction-controller@62.0.0 [61.3.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@61.2.0...@metamask/transaction-controller@61.3.0 [61.2.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@61.1.0...@metamask/transaction-controller@61.2.0 diff --git a/packages/transaction-controller/jest.config.js b/packages/transaction-controller/jest.config.js index 4b75427e9d2..5fc74a1ac09 100644 --- a/packages/transaction-controller/jest.config.js +++ b/packages/transaction-controller/jest.config.js @@ -18,7 +18,7 @@ module.exports = merge(baseConfig, { coverageThreshold: { global: { branches: 91.76, - functions: 92.76, + functions: 92.46, lines: 96.83, statements: 96.82, }, diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 0563188c56f..7009b9d4bf4 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-controller", - "version": "62.0.0", + "version": "62.5.0", "description": "Stores transactions alongside their periodically updated statuses and manages interactions such as approval and cancellation", "keywords": [ "MetaMask", @@ -55,12 +55,17 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@ethersproject/wallet": "^5.7.0", + "@metamask/accounts-controller": "^35.0.0", + "@metamask/approval-controller": "^8.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.16.0", "@metamask/eth-query": "^4.0.0", + "@metamask/gas-fee-controller": "^26.0.0", "@metamask/messenger": "^0.3.0", "@metamask/metamask-eth-abis": "^3.1.1", + "@metamask/network-controller": "^27.0.0", "@metamask/nonce-tracker": "^6.0.0", + "@metamask/remote-feature-flag-controller": "^3.0.0", "@metamask/rpc-errors": "^7.0.2", "@metamask/utils": "^11.8.1", "async-mutex": "^0.5.0", @@ -73,15 +78,10 @@ }, "devDependencies": { "@babel/runtime": "^7.23.9", - "@metamask/accounts-controller": "^35.0.0", - "@metamask/approval-controller": "^8.0.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-block-tracker": "^15.0.0", "@metamask/eth-json-rpc-provider": "^6.0.0", "@metamask/ethjs-provider-http": "^0.3.0", - "@metamask/gas-fee-controller": "^26.0.0", - "@metamask/network-controller": "^26.0.0", - "@metamask/remote-feature-flag-controller": "^2.0.1", "@ts-bridge/cli": "^0.6.4", "@types/bn.js": "^5.1.5", "@types/jest": "^27.4.1", @@ -98,12 +98,7 @@ }, "peerDependencies": { "@babel/runtime": "^7.0.0", - "@metamask/accounts-controller": "^35.0.0", - "@metamask/approval-controller": "^8.0.0", - "@metamask/eth-block-tracker": ">=9", - "@metamask/gas-fee-controller": "^26.0.0", - "@metamask/network-controller": "^26.0.0", - "@metamask/remote-feature-flag-controller": "^2.0.0" + "@metamask/eth-block-tracker": ">=9" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index fd719fdc49a..006a27ade5b 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -1,4 +1,9 @@ +/* eslint-disable jest/unbound-method */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable camelcase */ +/* eslint-disable @typescript-eslint/unbound-method */ /* eslint-disable jest/expect-expect */ + import { TransactionFactory } from '@ethereumjs/tx'; import type { AddApprovalRequest, @@ -15,12 +20,11 @@ import { import type { InternalProvider } from '@metamask/eth-json-rpc-provider'; import EthQuery from '@metamask/eth-query'; import HttpProvider from '@metamask/ethjs-provider-http'; -import { - Messenger, - type MockAnyNamespace, - type MessengerActions, - type MessengerEvents, - MOCK_ANY_NAMESPACE, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, } from '@metamask/messenger'; import type { BlockTracker, @@ -186,7 +190,7 @@ function buildMockEthQuery(): EthQuery { return { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - estimateGas: (_transaction: any, callback: any) => { + estimateGas: (_transaction: any, callback: any): void => { if (mockFlags.estimateGasError) { callback(new Error(mockFlags.estimateGasError)); return; @@ -200,7 +204,7 @@ function buildMockEthQuery(): EthQuery { }, // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - gasPrice: (callback: any) => { + gasPrice: (callback: any): void => { callback(undefined, '0x0'); }, getBlockByNumber: ( @@ -211,7 +215,7 @@ function buildMockEthQuery(): EthQuery { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any callback: any, - ) => { + ): void => { if (mockFlags.getBlockByNumberValue) { callback(undefined, { gasLimit: '0x12a05f200' }); return; @@ -220,12 +224,12 @@ function buildMockEthQuery(): EthQuery { }, // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - getCode: (_to: any, callback: any) => { + getCode: (_to: any, callback: any): void => { callback(undefined, '0x0'); }, // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - getTransactionByHash: (_hash: string, callback: any) => { + getTransactionByHash: (_hash: string, callback: any): void => { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const txs: any = [ @@ -239,17 +243,17 @@ function buildMockEthQuery(): EthQuery { }, // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - getTransactionCount: (_from: any, _to: any, callback: any) => { + getTransactionCount: (_from: any, _to: any, callback: any): void => { callback(undefined, '0x0'); }, // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - sendRawTransaction: (_transaction: unknown, callback: any) => { + sendRawTransaction: (_transaction: unknown, callback: any): void => { callback(undefined, TRANSACTION_HASH_MOCK); }, // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - getTransactionReceipt: (_hash: any, callback: any) => { + getTransactionReceipt: (_hash: any, callback: any): void => { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const txs: any = [ @@ -274,7 +278,7 @@ function buildMockEthQuery(): EthQuery { }, // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - getBlockByHash: (_blockHash: any, callback: any) => { + getBlockByHash: (_blockHash: any, callback: any): void => { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const blocks: any = [ @@ -295,7 +299,7 @@ function buildMockEthQuery(): EthQuery { ); callback(undefined, block); }, - sendAsync: () => { + sendAsync: (): void => { // do nothing }, }; @@ -347,7 +351,7 @@ function waitForTransactionFinished( ? 'TransactionController:transactionConfirmed' : 'TransactionController:transactionFinished'; return new Promise((resolve) => { - const subscriber = (transactionMeta: TransactionMeta) => { + const subscriber = (transactionMeta: TransactionMeta): void => { resolve(transactionMeta); messenger.unsubscribe(eventName, subscriber); }; @@ -650,7 +654,21 @@ describe('TransactionController', () => { NetworkClientConfiguration >; updateToInitialState?: boolean; - } = {}) { + } = {}): { + controller: TransactionController; + messenger: RootMessenger; + rootMessenger: RootMessenger; + mockTransactionApprovalRequest: { + promise: Promise; + approve: (approvalResult?: Partial) => void; + reject: (rejectionError: unknown) => void; + actionHandlerMock: jest.Mock; + }; + mockGetSelectedAccount: jest.Mock; + changeNetwork: (params: { + selectedNetworkClientId: NetworkClientId; + }) => void; + } { let networkState = { ...getDefaultNetworkControllerState(), selectedNetworkClientId: MOCK_NETWORK.state.selectedNetworkClientId, @@ -661,7 +679,7 @@ describe('TransactionController', () => { selectedNetworkClientId, }: { selectedNetworkClientId: NetworkClientId; - }) => { + }): void => { networkState = { ...networkState, selectedNetworkClientId, @@ -826,7 +844,7 @@ describe('TransactionController', () => { } { const { promise, resolve, reject } = createDeferredPromise(); - const approveTransaction = (approvalResult?: Partial) => { + const approveTransaction = (approvalResult?: Partial): void => { resolve({ resultCallbacks: { success() { @@ -844,7 +862,7 @@ describe('TransactionController', () => { rejectionError: unknown = { code: errorCodes.provider.userRejectedRequest, }, - ) => { + ): void => { reject(rejectionError); }; @@ -876,7 +894,7 @@ describe('TransactionController', () => { * * @param ms - The number of milliseconds to wait. */ - async function wait(ms: number) { + async function wait(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } @@ -1131,6 +1149,74 @@ describe('TransactionController', () => { expect(transactions[5].status).toBe(TransactionStatus.failed); }); + it('fails required transactions of approved and signed transactions', async () => { + const mockTransactionMeta = { + from: ACCOUNT_MOCK, + txParams: { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + }; + const mockedTransactions = [ + { + id: '123', + history: [{ ...mockTransactionMeta, id: '123' }], + chainId: toHex(5), + status: TransactionStatus.approved, + requiredTransactionIds: ['222', '333'], + ...mockTransactionMeta, + }, + { + id: '111', + history: [{ ...mockTransactionMeta, id: '111' }], + chainId: toHex(5), + status: TransactionStatus.signed, + ...mockTransactionMeta, + }, + { + id: '222', + history: [{ ...mockTransactionMeta, id: '222' }], + chainId: toHex(1), + status: TransactionStatus.confirmed, + ...mockTransactionMeta, + }, + { + id: '333', + history: [{ ...mockTransactionMeta, id: '333' }], + chainId: toHex(16), + status: TransactionStatus.submitted, + ...mockTransactionMeta, + }, + ]; + + const mockedControllerState = { + transactions: mockedTransactions, + methodData: {}, + lastFetchedBlockNumbers: {}, + }; + + const { controller } = setupController({ + options: { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state: mockedControllerState as any, + }, + }); + + await flushPromises(); + + const { transactions } = controller.state; + + expect( + transactions.map((transaction) => [transaction.id, transaction.status]), + ).toStrictEqual([ + ['333', TransactionStatus.failed], + ['222', TransactionStatus.confirmed], + ['111', TransactionStatus.failed], + ['123', TransactionStatus.failed], + ]); + }); + it('removes unapproved transactions', async () => { const mockTransactionMeta = { from: ACCOUNT_MOCK, @@ -1574,8 +1660,10 @@ describe('TransactionController', () => { maxPriorityFeePerGas: '0x1', }, { - getCurrentNetworkEIP1559Compatibility: async () => true, - getCurrentAccountEIP1559Compatibility: async () => true, + getCurrentNetworkEIP1559Compatibility: async (): Promise => + true, + getCurrentAccountEIP1559Compatibility: async (): Promise => + true, }, ], [TransactionEnvelopeType.accessList, { accessList: [] }], @@ -1686,7 +1774,6 @@ describe('TransactionController', () => { batchId: undefined, chainId: expect.any(String), dappSuggestedGasFees: undefined, - delegationAddress: undefined, deviceConfirmedOn: undefined, disableGasBuffer: undefined, id: expect.any(String), @@ -2982,7 +3069,7 @@ describe('TransactionController', () => { async function expectTransactionToFail( controller: TransactionController, expectedError: string, - ) { + ): Promise { const { result } = await controller.addTransaction( { from: ACCOUNT_MOCK, @@ -5663,7 +5750,7 @@ describe('TransactionController', () => { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any ...args: any - ) { + ): void { (pendingTransactionTrackerMock.hub.on as jest.Mock).mock.calls.find( (call) => call[0] === eventName, )[1](...args); @@ -5949,7 +6036,6 @@ describe('TransactionController', () => { }; // Send the transaction to put it in the process of being signed - // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/no-floating-promises controller.approveTransactionsWithSameNonce([mockTransactionParam]); @@ -6842,13 +6928,12 @@ describe('TransactionController', () => { const updatedTransaction = controller.state.transactions[0]; const pathParts = expectedPath.split('.'); - let actualValue = updatedTransaction; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let actualValue: any = updatedTransaction; for (const key of pathParts) { // Type assertion needed since we're accessing dynamic properties - actualValue = actualValue[ - key as keyof typeof actualValue - ] as typeof actualValue; + actualValue = actualValue[key as keyof typeof actualValue]; } expect(actualValue).toStrictEqual(newValue); @@ -7071,7 +7156,7 @@ describe('TransactionController', () => { controller.getTransactions({ // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - searchCriteria: { time: (v: any) => v === 1 }, + searchCriteria: { time: (value: any) => value === 1 }, }), ).toStrictEqual([transactions[0], transactions[2]]); }); @@ -7751,7 +7836,10 @@ describe('TransactionController', () => { * * @returns The controller instance and function result; */ - async function updateAtomicBatchDataTemplate() { + async function updateAtomicBatchDataTemplate(): Promise<{ + controller: TransactionController; + result: Hex; + }> { const { controller } = setupController({ options: { state: { @@ -8499,7 +8587,7 @@ describe('TransactionController', () => { expect(result).toStrictEqual([GAS_FEE_TOKEN_MOCK]); }); - it('includes delegation address in request', async () => { + it('includes delegation address and isExternalSign in request', async () => { const { messenger } = setupController(); getGasFeeTokensMock.mockResolvedValueOnce({ @@ -8521,6 +8609,7 @@ describe('TransactionController', () => { expect.objectContaining({ transactionMeta: expect.objectContaining({ delegationAddress: ACCOUNT_2_MOCK, + isExternalSign: true, }), }), ); diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 11c55960100..3196fabea81 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -1,5 +1,6 @@ import type { TypedTransaction } from '@ethereumjs/tx'; import type { + AccountsController, AccountsControllerGetSelectedAccountAction, AccountsControllerGetStateAction, } from '@metamask/accounts-controller'; @@ -56,7 +57,7 @@ import { add0x } from '@metamask/utils'; // This package purposefully relies on Node's EventEmitter module. // eslint-disable-next-line import-x/no-nodejs-modules import { EventEmitter } from 'events'; -import { cloneDeep, mapValues, merge, pickBy, sortBy } from 'lodash'; +import { cloneDeep, mapValues, merge, noop, pickBy, sortBy } from 'lodash'; import { v1 as random } from 'uuid'; import { DefaultGasFeeFlow } from './gas-flows/DefaultGasFeeFlow'; @@ -120,6 +121,7 @@ import type { AddTransactionOptions, PublishHookResult, GetGasFeeTokensRequest, + InternalAccount, } from './types'; import { GasFeeEstimateLevel, @@ -969,44 +971,60 @@ export class TransactionController extends BaseController< this.messenger = messenger; - this.#afterAdd = hooks?.afterAdd ?? (() => Promise.resolve({})); - this.#afterSign = hooks?.afterSign ?? (() => true); - this.#afterSimulate = hooks?.afterSimulate ?? (() => Promise.resolve({})); + this.#afterAdd = + hooks?.afterAdd ?? ((): ReturnType => Promise.resolve({})); + this.#afterSign = hooks?.afterSign ?? ((): boolean => true); + this.#afterSimulate = + hooks?.afterSimulate ?? + ((): ReturnType => Promise.resolve({})); this.#beforeCheckPendingTransaction = /* istanbul ignore next */ - hooks?.beforeCheckPendingTransaction ?? (() => Promise.resolve(true)); - this.#beforePublish = hooks?.beforePublish ?? (() => Promise.resolve(true)); - this.#beforeSign = hooks?.beforeSign ?? (() => Promise.resolve({})); + hooks?.beforeCheckPendingTransaction ?? + ((): Promise => Promise.resolve(true)); + this.#beforePublish = + hooks?.beforePublish ?? ((): Promise => Promise.resolve(true)); + this.#beforeSign = + hooks?.beforeSign ?? + ((): ReturnType => Promise.resolve({})); this.#getAdditionalSignArguments = - hooks?.getAdditionalSignArguments ?? (() => []); + hooks?.getAdditionalSignArguments ?? + ((): (TransactionMeta | undefined)[] => []); this.#getCurrentAccountEIP1559Compatibility = - getCurrentAccountEIP1559Compatibility ?? (() => Promise.resolve(true)); + getCurrentAccountEIP1559Compatibility ?? + ((): Promise => Promise.resolve(true)); this.#getCurrentNetworkEIP1559Compatibility = getCurrentNetworkEIP1559Compatibility; this.#getExternalPendingTransactions = - getExternalPendingTransactions ?? (() => []); + getExternalPendingTransactions ?? ((): NonceTrackerTransaction[] => []); this.#getGasFeeEstimates = - getGasFeeEstimates || (() => Promise.resolve({} as GasFeeState)); + getGasFeeEstimates ?? + ((): Promise => Promise.resolve({} as GasFeeState)); this.#getNetworkState = getNetworkState; this.#getPermittedAccounts = getPermittedAccounts; - this.#getSavedGasFees = getSavedGasFees ?? ((_chainId) => undefined); + this.#getSavedGasFees = + getSavedGasFees ?? ((_chainId): SavedGasFees | undefined => undefined); this.#getSimulationConfig = - getSimulationConfig ?? (() => Promise.resolve({})); + getSimulationConfig ?? + ((): ReturnType => Promise.resolve({})); this.#incomingTransactionOptions = incomingTransactions; this.#isAutomaticGasFeeUpdateEnabled = - isAutomaticGasFeeUpdateEnabled ?? ((_txMeta: TransactionMeta) => false); + isAutomaticGasFeeUpdateEnabled ?? + ((_txMeta: TransactionMeta): boolean => false); this.#isEIP7702GasFeeTokensEnabled = - isEIP7702GasFeeTokensEnabled ?? (() => Promise.resolve(false)); + isEIP7702GasFeeTokensEnabled ?? + ((): Promise => Promise.resolve(false)); this.#isFirstTimeInteractionEnabled = - isFirstTimeInteractionEnabled ?? (() => true); + isFirstTimeInteractionEnabled ?? ((): boolean => true); this.#isHistoryDisabled = disableHistory ?? false; this.#isSendFlowHistoryDisabled = disableSendFlowHistory ?? false; - this.#isSimulationEnabled = isSimulationEnabled ?? (() => true); + this.#isSimulationEnabled = isSimulationEnabled ?? ((): boolean => true); this.#isSwapsDisabled = disableSwaps ?? false; this.#pendingTransactionOptions = pendingTransactions; this.#publicKeyEIP7702 = publicKeyEIP7702; this.#publish = - hooks?.publish ?? (() => Promise.resolve({ transactionHash: undefined })); + hooks?.publish ?? + ((): Promise<{ transactionHash?: string }> => + Promise.resolve({ transactionHash: undefined })); this.#publishBatchHook = hooks?.publishBatch; this.#securityProviderRequest = securityProviderRequest; this.#sign = sign; @@ -1014,7 +1032,7 @@ export class TransactionController extends BaseController< this.#trace = trace ?? (((_request, fn) => fn?.()) as TraceCallback); this.#transactionHistoryLimit = transactionHistoryLimit; - const findNetworkClientIdByChainId = (chainId: Hex) => { + const findNetworkClientIdByChainId = (chainId: Hex): string => { return this.messenger.call( `NetworkController:findNetworkClientIdByChainId`, chainId, @@ -1035,7 +1053,7 @@ export class TransactionController extends BaseController< createNonceTracker: this.#createNonceTracker.bind(this), createPendingTransactionTracker: this.#createPendingTransactionTracker.bind(this), - onNetworkStateChange: (listener) => { + onNetworkStateChange: (listener): void => { this.messenger.subscribe('NetworkController:stateChange', listener); }, }); @@ -1048,12 +1066,14 @@ export class TransactionController extends BaseController< findNetworkClientIdByChainId, gasFeeFlows: this.#gasFeeFlows, getGasFeeControllerEstimates: this.#getGasFeeEstimates, - getProvider: (networkClientId) => this.#getProvider({ networkClientId }), - getTransactions: () => this.state.transactions, - getTransactionBatches: () => this.state.transactionBatches, + getProvider: (networkClientId): Provider => + this.#getProvider({ networkClientId }), + getTransactions: (): TransactionMeta[] => this.state.transactions, + getTransactionBatches: (): TransactionBatchMeta[] => + this.state.transactionBatches, layer1GasFeeFlows: this.#layer1GasFeeFlows, messenger: this.messenger, - onStateChange: (listener) => { + onStateChange: (listener): void => { this.messenger.subscribe('TransactionController:stateChange', listener); }, }); @@ -1069,8 +1089,9 @@ export class TransactionController extends BaseController< ); this.#methodDataHelper = new MethodDataHelper({ - getProvider: (networkClientId) => this.#getProvider({ networkClientId }), - getState: () => this.state.methodData, + getProvider: (networkClientId): Provider => + this.#getProvider({ networkClientId }), + getState: (): Record => this.state.methodData, }); this.#methodDataHelper.hub.on( @@ -1084,8 +1105,10 @@ export class TransactionController extends BaseController< this.#incomingTransactionHelper = new IncomingTransactionHelper({ client: this.#incomingTransactionOptions.client, - getCurrentAccount: () => this.#getSelectedAccount(), - getLocalTransactions: () => this.state.transactions, + getCurrentAccount: (): ReturnType< + AccountsController['getSelectedAccount'] + > => this.#getSelectedAccount(), + getLocalTransactions: (): TransactionMeta[] => this.state.transactions, includeTokenTransfers: this.#incomingTransactionOptions.includeTokenTransfers, isEnabled: this.#incomingTransactionOptions.isEnabled, @@ -1106,16 +1129,17 @@ export class TransactionController extends BaseController< this.#checkForPendingTransactionAndStartPolling, ); + // eslint-disable-next-line no-new new ResimulateHelper({ simulateTransaction: this.#updateSimulationData.bind(this), - onTransactionsUpdate: (listener) => { + onTransactionsUpdate: (listener): void => { this.messenger.subscribe( 'TransactionController:stateChange', listener, (controllerState) => controllerState.transactions, ); }, - getTransactions: () => this.state.transactions, + getTransactions: (): TransactionMeta[] => this.state.transactions, }); this.#onBootCleanup(); @@ -1126,7 +1150,7 @@ export class TransactionController extends BaseController< /** * Stops polling and removes listeners to prepare the controller for garbage collection. */ - destroy() { + destroy(): void { this.#stopAllTracking(); } @@ -1239,17 +1263,17 @@ export class TransactionController extends BaseController< requireApproval, securityAlertResponse, sendFlowHistory, + skipInitialGasEstimate, swaps = {}, traceContext, type, } = options; + // eslint-disable-next-line no-param-reassign txParams = normalizeTransactionParams(txParams); if (!this.#multichainTrackingHelper.has(networkClientId)) { - throw new Error( - `Network client not found - ${networkClientId as string}`, - ); + throw new Error(`Network client not found - ${networkClientId}`); } const chainId = this.#getChainId(networkClientId); @@ -1311,8 +1335,6 @@ export class TransactionController extends BaseController< const transactionType = type ?? (await determineTransactionType(txParams, ethQuery)).type; - const delegationAddress = await delegationAddressPromise; - const existingTransactionMeta = this.#getTransactionWithActionId(actionId); // If a request to add a transaction with the same actionId is submitted again, a new transaction will not be created for it. @@ -1325,7 +1347,6 @@ export class TransactionController extends BaseController< batchId, chainId, dappSuggestedGasFees, - delegationAddress, deviceConfirmedOn, disableGasBuffer, id: random(), @@ -1360,13 +1381,41 @@ export class TransactionController extends BaseController< updateTransaction(addedTransactionMeta); } - await this.#trace( - { name: 'Estimate Gas Properties', parentContext: traceContext }, - (context) => - this.#updateGasProperties(addedTransactionMeta, { - traceContext: context, - }), - ); + // eslint-disable-next-line no-negated-condition + if (!skipInitialGasEstimate) { + await this.#trace( + { name: 'Estimate Gas Properties', parentContext: traceContext }, + (context) => + this.#updateGasProperties(addedTransactionMeta, { + traceContext: context, + }), + ); + } else { + const newTransactionMeta = cloneDeep(addedTransactionMeta); + + this.#updateGasProperties(newTransactionMeta) + .then(() => { + this.#updateTransactionInternal( + { + transactionId: newTransactionMeta.id, + skipHistory: true, + skipResimulateCheck: true, + skipValidation: true, + }, + (tx) => { + tx.txParams.gas = newTransactionMeta.txParams.gas; + tx.txParams.gasPrice = newTransactionMeta.txParams.gasPrice; + tx.txParams.maxFeePerGas = + newTransactionMeta.txParams.maxFeePerGas; + tx.txParams.maxPriorityFeePerGas = + newTransactionMeta.txParams.maxPriorityFeePerGas; + }, + ); + + return undefined; + }) + .catch(noop); + } // Checks if a transaction already exists with a given actionId if (!existingTransactionMeta) { @@ -1376,11 +1425,13 @@ export class TransactionController extends BaseController< addedTransactionMeta, method, ); + // eslint-disable-next-line require-atomic-updates addedTransactionMeta.securityProviderResponse = securityProviderResponse; } if (!this.#isSendFlowHistoryDisabled) { + // eslint-disable-next-line require-atomic-updates addedTransactionMeta.sendFlowHistory = sendFlowHistory ?? []; } // Initial history push @@ -1401,6 +1452,25 @@ export class TransactionController extends BaseController< this.#addMetadata(addedTransactionMeta); + delegationAddressPromise + .then((delegationAddress) => { + this.#updateTransactionInternal( + { + transactionId: addedTransactionMeta.id, + skipHistory: true, + skipResimulateCheck: true, + skipValidation: true, + }, + (tx) => { + tx.delegationAddress = delegationAddress; + }, + ); + + return undefined; + }) + .catch(noop); + + // eslint-disable-next-line no-negated-condition if (requireApproval !== false) { this.#updateSimulationData(addedTransactionMeta, { traceContext, @@ -1445,11 +1515,11 @@ export class TransactionController extends BaseController< }; } - startIncomingTransactionPolling() { + startIncomingTransactionPolling(): void { this.#incomingTransactionHelper.start(); } - stopIncomingTransactionPolling() { + stopIncomingTransactionPolling(): void { this.#incomingTransactionHelper.stop(); } @@ -1459,7 +1529,9 @@ export class TransactionController extends BaseController< * @param request - Request object. * @param request.tags - Additional tags to identify the source of the request. */ - async updateIncomingTransactions({ tags }: { tags?: string[] } = {}) { + async updateIncomingTransactions({ + tags, + }: { tags?: string[] } = {}): Promise { await this.#incomingTransactionHelper.update({ tags }); } @@ -1480,7 +1552,7 @@ export class TransactionController extends BaseController< estimatedBaseFee, actionId, }: { estimatedBaseFee?: string; actionId?: string } = {}, - ) { + ): Promise { await this.#retryTransaction({ actionId, estimatedBaseFee, @@ -1524,7 +1596,7 @@ export class TransactionController extends BaseController< actionId, estimatedBaseFee, }: { actionId?: string; estimatedBaseFee?: string } = {}, - ) { + ): Promise { await this.#retryTransaction({ actionId, estimatedBaseFee, @@ -1562,7 +1634,7 @@ export class TransactionController extends BaseController< rate: number; transactionId: string; transactionType: TransactionType; - }) { + }): Promise { // If transaction is found for same action id, do not create a new transaction. if (this.#getTransactionWithActionId(actionId)) { return; @@ -1570,6 +1642,7 @@ export class TransactionController extends BaseController< if (gasValues) { // Not good practice to reassign a parameter but temporarily avoiding a larger refactor. + // eslint-disable-next-line no-param-reassign gasValues = normalizeGasFeeValues(gasValues); validateGasValues(gasValues); } @@ -1680,7 +1753,10 @@ export class TransactionController extends BaseController< }: { ignoreDelegationSignatures?: boolean; } = {}, - ) { + ): Promise<{ + gas: string; + simulationFails: TransactionMeta['simulationFails']; + }> { const ethQuery = this.#getEthQuery({ networkClientId, }); @@ -1710,7 +1786,10 @@ export class TransactionController extends BaseController< transaction: TransactionParams, multiplier: number, networkClientId: NetworkClientId, - ) { + ): Promise<{ + gas: string; + simulationFails: TransactionMeta['simulationFails']; + }> { const ethQuery = this.#getEthQuery({ networkClientId, }); @@ -1738,7 +1817,7 @@ export class TransactionController extends BaseController< * @param transactionMeta - The new transaction to store in state. * @param note - A note or update reason to include in the transaction history. */ - updateTransaction(transactionMeta: TransactionMeta, note: string) { + updateTransaction(transactionMeta: TransactionMeta, note: string): void { const { id: transactionId } = transactionMeta; this.#updateTransactionInternal({ transactionId, note }, () => ({ @@ -1755,7 +1834,7 @@ export class TransactionController extends BaseController< updateSecurityAlertResponse( transactionId: string, securityAlertResponse: SecurityAlertResponse, - ) { + ): void { if (!securityAlertResponse) { throw new Error( 'updateSecurityAlertResponse: securityAlertResponse should not be null', @@ -1790,7 +1869,7 @@ export class TransactionController extends BaseController< }: { address?: string; chainId?: string; - } = {}) { + } = {}): void { if (!chainId && !address) { this.update((state) => { state.transactions = []; @@ -1833,7 +1912,7 @@ export class TransactionController extends BaseController< transactionMeta: TransactionMeta, transactionReceipt: TransactionReceipt, baseFeePerGas: Hex, - ) { + ): Promise { // Run validation and add external transaction to state. const newTransactionMeta = this.#addExternalTransaction(transactionMeta); @@ -2162,7 +2241,7 @@ export class TransactionController extends BaseController< updateType?: boolean; value?: string; }, - ) { + ): Promise | undefined> { const transactionMeta = this.#getTransaction(txId); if (!transactionMeta) { @@ -2230,7 +2309,7 @@ export class TransactionController extends BaseController< * @param transactionId - The ID of the transaction to update. * @param isActive - The active state. */ - setTransactionActive(transactionId: string, isActive: boolean) { + setTransactionActive(transactionId: string, isActive: boolean): void { const transactionMeta = this.#getTransaction(transactionId); if (!transactionMeta) { @@ -2307,11 +2386,11 @@ export class TransactionController extends BaseController< return this.#signExternalTransaction(txParams.chainId, txParams); }), ); - } catch (err) { - log('Error while signing transactions with same nonce', err); + } catch (error) { + log('Error while signing transactions with same nonce', error); // Must set transaction to submitted/failed before releasing lock // continue with error chain - throw err; + throw error; } finally { nonceLock?.releaseLock(); this.#approvingTransactionIds.delete(initialTxAsSerializedHex); @@ -2326,7 +2405,9 @@ export class TransactionController extends BaseController< * * @returns The updated transaction metadata. */ - updateCustodialTransaction(request: UpdateCustodialTransactionRequest) { + updateCustodialTransaction( + request: UpdateCustodialTransactionRequest, + ): TransactionMeta { const { transactionId, errorMessage, @@ -2401,9 +2482,7 @@ export class TransactionController extends BaseController< if ( status && - [TransactionStatus.submitted, TransactionStatus.failed].includes( - status as TransactionStatus, - ) + [TransactionStatus.submitted, TransactionStatus.failed].includes(status) ) { this.messenger.publish( `${controllerName}:transactionFinished`, @@ -2449,7 +2528,7 @@ export class TransactionController extends BaseController< ? predicate : // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - (v: any) => v === predicate; + (value: any): boolean => value === predicate; }); const transactionsToFilter = initialList ?? this.state.transactions; @@ -2626,7 +2705,7 @@ export class TransactionController extends BaseController< /** * Removes unapproved transactions from state. */ - clearUnapprovedTransactions() { + clearUnapprovedTransactions(): void { const transactions = this.state.transactions.filter( ({ status }) => status !== TransactionStatus.unapproved, ); @@ -2641,7 +2720,7 @@ export class TransactionController extends BaseController< * * @param transactionId - The ID of the transaction to stop signing. */ - abortTransactionSigning(transactionId: string) { + abortTransactionSigning(transactionId: string): void { const transactionMeta = this.#getTransaction(transactionId); if (!transactionMeta) { @@ -2678,7 +2757,7 @@ export class TransactionController extends BaseController< transactionId: string; transactionIndex: number; transactionData: Hex; - }) { + }): Promise { log('Updating atomic batch data', { transactionId, transactionIndex, @@ -2752,7 +2831,7 @@ export class TransactionController extends BaseController< }: { transactionId: string; batchTransactions: BatchTransactionParams[]; - }) { + }): void { log('Updating batch transactions', { transactionId, batchTransactions }); this.#updateTransactionInternal( @@ -2775,7 +2854,7 @@ export class TransactionController extends BaseController< updateSelectedGasFeeToken( transactionId: string, contractAddress: Hex | undefined, - ) { + ): void { this.#updateTransactionInternal({ transactionId }, (transactionMeta) => { const hasMatchingGasFeeToken = transactionMeta.gasFeeTokens?.some( (token) => @@ -2808,7 +2887,7 @@ export class TransactionController extends BaseController< transactionId: string; requiredTransactionIds: string[]; append?: boolean; - }) { + }): void { this.#updateTransactionInternal({ transactionId }, (transactionMeta) => { const { requiredTransactionIds: existing } = transactionMeta; @@ -2824,7 +2903,7 @@ export class TransactionController extends BaseController< * * @param transactionId - The transaction ID. */ - emulateNewTransaction(transactionId: string) { + emulateNewTransaction(transactionId: string): void { const transactionMeta = this.state.transactions.find( (tx) => tx.id === transactionId, ); @@ -2850,7 +2929,7 @@ export class TransactionController extends BaseController< * * @param transactionMeta - Transaction metadata. */ - emulateTransactionUpdate(transactionMeta: TransactionMeta) { + emulateTransactionUpdate(transactionMeta: TransactionMeta): void { const updatedTransactionMeta = { ...transactionMeta, txParams: { @@ -2880,7 +2959,7 @@ export class TransactionController extends BaseController< }); } - #addMetadata(transactionMeta: TransactionMeta) { + #addMetadata(transactionMeta: TransactionMeta): void { validateTxParams(transactionMeta.txParams); this.update((state) => { state.transactions = this.#trimTransactionsForState([ @@ -2893,7 +2972,7 @@ export class TransactionController extends BaseController< async #updateGasProperties( transactionMeta: TransactionMeta, { traceContext }: { traceContext?: TraceContext } = {}, - ) { + ): Promise { const isEIP1559Compatible = transactionMeta.txParams.type !== TransactionEnvelopeType.legacy && (await this.#getEIP1559Compatibility(transactionMeta.networkClientId)); @@ -2935,12 +3014,12 @@ export class TransactionController extends BaseController< ); } - #onBootCleanup() { + #onBootCleanup(): void { this.clearUnapprovedTransactions(); this.#failIncompleteTransactions(); } - #failIncompleteTransactions() { + #failIncompleteTransactions(): void { const incompleteTransactions = this.state.transactions.filter( (transaction) => [TransactionStatus.approved, TransactionStatus.signed].includes( @@ -2953,6 +3032,27 @@ export class TransactionController extends BaseController< transactionMeta, new Error('Transaction incomplete at startup'), ); + + const requiredTransactionIds = + transactionMeta.requiredTransactionIds ?? []; + + for (const requiredTransactionId of requiredTransactionIds) { + const requiredTransactionMeta = this.#getTransaction( + requiredTransactionId, + ); + + if ( + !requiredTransactionMeta || + this.#isFinalState(requiredTransactionMeta.status) + ) { + continue; + } + + this.#failTransaction( + requiredTransactionMeta, + new Error('Parent transaction incomplete at startup'), + ); + } } } @@ -3061,24 +3161,26 @@ export class TransactionController extends BaseController< const finalMeta = await finishedPromise; switch (finalMeta?.status) { - case TransactionStatus.failed: + case TransactionStatus.failed: { const error = finalMeta.error as Error; resultCallbacks?.error(error); throw rpcErrors.internal(error.message); + } case TransactionStatus.submitted: resultCallbacks?.success(); return finalMeta.hash as string; - default: + default: { const internalError = rpcErrors.internal( `MetaMask Tx Signature: Unknown problem: ${JSON.stringify( - finalMeta || transactionId, + finalMeta ?? transactionId, )}`, ); resultCallbacks?.error(internalError); throw internalError; + } } } @@ -3097,7 +3199,7 @@ export class TransactionController extends BaseController< transactionId: string, traceContext?: unknown, publishHookOverride?: PublishHook, - ) { + ): Promise { let clearApprovingTransactionId: (() => void) | undefined; let clearNonceLock: (() => void) | undefined; @@ -3127,23 +3229,12 @@ export class TransactionController extends BaseController< this.#approvingTransactionIds.add(transactionId); - clearApprovingTransactionId = () => + clearApprovingTransactionId = (): boolean => this.#approvingTransactionIds.delete(transactionId); const { networkClientId } = transactionMeta; const ethQuery = this.#getEthQuery({ networkClientId }); - await checkGasFeeTokenBeforePublish({ - ethQuery, - fetchGasFeeTokens: async (tx) => - (await this.#getGasFeeTokens(tx)).gasFeeTokens, - transaction: transactionMeta, - updateTransaction: (txId, fn) => - this.#updateTransactionInternal({ transactionId: txId }, fn), - }); - - transactionMeta = this.#getTransactionOrThrow(transactionId); - const [nonce, releaseNonce] = await getNextNonce( transactionMeta, (address: string) => @@ -3155,6 +3246,7 @@ export class TransactionController extends BaseController< clearNonceLock = releaseNonce; + // eslint-disable-next-line require-atomic-updates transactionMeta = this.#updateTransactionInternal( { transactionId, @@ -3182,6 +3274,7 @@ export class TransactionController extends BaseController< () => this.#signTransaction(transactionMeta), ); + // eslint-disable-next-line require-atomic-updates transactionMeta = this.#getTransactionOrThrow(transactionId); if (!(await this.#beforePublish(transactionMeta))) { @@ -3237,6 +3330,7 @@ export class TransactionController extends BaseController< rawTx ?? '0x', ); + // eslint-disable-next-line require-atomic-updates transactionMeta = this.#updateTransactionInternal( { transactionId, @@ -3300,7 +3394,11 @@ export class TransactionController extends BaseController< * @param actionId - The actionId passed from UI * @param error - The error that caused the rejection. */ - #rejectTransaction(transactionId: string, actionId?: string, error?: Error) { + #rejectTransaction( + transactionId: string, + actionId?: string, + error?: Error, + ): void { const transactionMeta = this.#getTransaction(transactionId); if (!transactionMeta) { @@ -3390,7 +3488,8 @@ export class TransactionController extends BaseController< return ( status === TransactionStatus.rejected || status === TransactionStatus.confirmed || - status === TransactionStatus.failed + status === TransactionStatus.failed || + status === TransactionStatus.dropped ); } @@ -3431,7 +3530,7 @@ export class TransactionController extends BaseController< 'ApprovalController:addRequest', { id, - origin: origin || ORIGIN_METAMASK, + origin: origin ?? ORIGIN_METAMASK, type, requestData, expectsResult: true, @@ -3460,7 +3559,7 @@ export class TransactionController extends BaseController< return txMeta; } - #getApprovalId(txMeta: TransactionMeta) { + #getApprovalId(txMeta: TransactionMeta): string { return String(txMeta.id); } @@ -3490,7 +3589,7 @@ export class TransactionController extends BaseController< }: { chainId?: Hex; networkClientId?: NetworkClientId; - }) { + }): NetworkClientId { if (networkClientId) { return networkClientId; } @@ -3523,7 +3622,7 @@ export class TransactionController extends BaseController< }).provider; } - #onIncomingTransactions(transactions: TransactionMeta[]) { + #onIncomingTransactions(transactions: TransactionMeta[]): void { if (!transactions.length) { return; } @@ -3613,7 +3712,7 @@ export class TransactionController extends BaseController< * @param transactionMeta - Nominated external transaction to be added to state. * @returns The new transaction. */ - #addExternalTransaction(transactionMeta: TransactionMeta) { + #addExternalTransaction(transactionMeta: TransactionMeta): TransactionMeta { const { chainId } = transactionMeta; const { transactions } = this.state; const fromAddress = transactionMeta?.txParams?.from; @@ -3657,7 +3756,7 @@ export class TransactionController extends BaseController< * * @param transactionId - Used to identify original transaction. */ - #markNonceDuplicatesDropped(transactionId: string) { + #markNonceDuplicatesDropped(transactionId: string): void { const transactionMeta = this.#getTransaction(transactionId); if (!transactionMeta) { return; @@ -3707,7 +3806,7 @@ export class TransactionController extends BaseController< * * @param transactionMeta - TransactionMeta of transaction to be marked as dropped. */ - #setTransactionStatusDropped(transactionMeta: TransactionMeta) { + #setTransactionStatusDropped(transactionMeta: TransactionMeta): void { const updatedTransactionMeta = { ...transactionMeta, status: TransactionStatus.dropped as const, @@ -3728,7 +3827,7 @@ export class TransactionController extends BaseController< * @param actionId - Unique ID to prevent duplicate requests * @returns the filtered transaction */ - #getTransactionWithActionId(actionId?: string) { + #getTransactionWithActionId(actionId?: string): TransactionMeta | undefined { return this.state.transactions.find( (transaction) => actionId && transaction.actionId === actionId, ); @@ -3771,7 +3870,9 @@ export class TransactionController extends BaseController< return transactionMetaWithRsv; } - async #getEIP1559Compatibility(networkClientId?: NetworkClientId) { + async #getEIP1559Compatibility( + networkClientId?: NetworkClientId, + ): Promise { const currentNetworkIsEIP1559Compatible = await this.#getCurrentNetworkEIP1559Compatibility(networkClientId); @@ -3908,7 +4009,7 @@ export class TransactionController extends BaseController< return rawTx; } - #onTransactionStatusChange(transactionMeta: TransactionMeta) { + #onTransactionStatusChange(transactionMeta: TransactionMeta): void { this.messenger.publish(`${controllerName}:transactionStatusUpdated`, { transactionMeta, }); @@ -3918,7 +4019,7 @@ export class TransactionController extends BaseController< statuses: TransactionStatus[], address: string, chainId: string, - ) { + ): NonceTrackerTransaction[] { return getAndFormatTransactionsForNonceTracker( chainId, address, @@ -3927,7 +4028,7 @@ export class TransactionController extends BaseController< ); } - #onConfirmedTransaction(transactionMeta: TransactionMeta) { + #onConfirmedTransaction(transactionMeta: TransactionMeta): void { log('Processing confirmed transaction', transactionMeta.id); this.#markNonceDuplicatesDropped(transactionMeta.id); @@ -3946,7 +4047,7 @@ export class TransactionController extends BaseController< }); } - async #updatePostBalance(transactionMeta: TransactionMeta) { + async #updatePostBalance(transactionMeta: TransactionMeta): Promise { try { const { networkClientId, type } = transactionMeta; @@ -4018,17 +4119,17 @@ export class TransactionController extends BaseController< const pendingTransactionTracker = new PendingTransactionTracker({ blockTracker, - getChainId: () => chainId, - getEthQuery: () => ethQuery, - getNetworkClientId: () => networkClientId, - getTransactions: () => this.state.transactions, + getChainId: (): Hex => chainId, + getEthQuery: (): EthQuery => ethQuery, + getNetworkClientId: (): NetworkClientId => networkClientId, + getTransactions: (): TransactionMeta[] => this.state.transactions, isResubmitEnabled: this.#pendingTransactionOptions.isResubmitEnabled, - getGlobalLock: () => + getGlobalLock: (): Promise<() => void> => this.#multichainTrackingHelper.acquireNonceLockForChainIdKey({ chainId, }), messenger: this.messenger, - publishTransaction: (_ethQuery, transactionMeta) => + publishTransaction: (_ethQuery, transactionMeta): Promise => this.#publishTransaction(_ethQuery, transactionMeta, { skipSubmitHistory: true, }), @@ -4043,17 +4144,17 @@ export class TransactionController extends BaseController< return pendingTransactionTracker; } - readonly #checkForPendingTransactionAndStartPolling = () => { + readonly #checkForPendingTransactionAndStartPolling = (): void => { this.#multichainTrackingHelper.checkForPendingTransactionAndStartPolling(); }; - #stopAllTracking() { + #stopAllTracking(): void { this.#multichainTrackingHelper.stopAllTracking(); } #addIncomingTransactionHelperListeners( incomingTransactionHelper: IncomingTransactionHelper, - ) { + ): void { incomingTransactionHelper.hub.on( 'transactions', this.#onIncomingTransactions.bind(this), @@ -4062,7 +4163,7 @@ export class TransactionController extends BaseController< #removePendingTransactionTrackerListeners( pendingTransactionTracker: PendingTransactionTracker, - ) { + ): void { pendingTransactionTracker.hub.removeAllListeners('transaction-confirmed'); pendingTransactionTracker.hub.removeAllListeners('transaction-dropped'); pendingTransactionTracker.hub.removeAllListeners('transaction-failed'); @@ -4071,7 +4172,7 @@ export class TransactionController extends BaseController< #addPendingTransactionTrackerListeners( pendingTransactionTracker: PendingTransactionTracker, - ) { + ): void { pendingTransactionTracker.hub.on( 'transaction-confirmed', this.#onConfirmedTransaction.bind(this), @@ -4093,7 +4194,10 @@ export class TransactionController extends BaseController< ); } - #getNonceTrackerPendingTransactions(chainId: string, address: string) { + #getNonceTrackerPendingTransactions( + chainId: string, + address: string, + ): NonceTrackerTransaction[] { const standardPendingTransactions = this.#getNonceTrackerTransactions( [ TransactionStatus.approved, @@ -4136,8 +4240,8 @@ export class TransactionController extends BaseController< // eslint-disable-next-line @typescript-eslint/no-explicit-any #isTransactionAlreadyConfirmedError(error: any): boolean { return ( - error?.message?.includes('nonce too low') || - error?.data?.message?.includes('nonce too low') + Boolean(error?.message?.includes('nonce too low')) || + Boolean(error?.data?.message?.includes('nonce too low')) ); } @@ -4243,7 +4347,7 @@ export class TransactionController extends BaseController< blockTime?: number; traceContext?: TraceContext; } = {}, - ) { + ): Promise { const { chainId, id: transactionId, @@ -4350,7 +4454,7 @@ export class TransactionController extends BaseController< gasFeeEstimates?: GasFeeEstimates; gasFeeEstimatesLoaded?: boolean; layer1GasFee?: Hex; - }) { + }): void { this.#updateTransactionInternal( { transactionId, skipHistory: true }, (txMeta) => { @@ -4371,7 +4475,7 @@ export class TransactionController extends BaseController< }: { transactionBatchId: Hex; gasFeeEstimates?: GasFeeEstimates; - }) { + }): void { this.#updateTransactionBatch(transactionBatchId, (batch) => { return { ...batch, gasFeeEstimates }; }); @@ -4395,7 +4499,7 @@ export class TransactionController extends BaseController< }); } - #getSelectedAccount() { + #getSelectedAccount(): InternalAccount { return this.messenger.call('AccountsController:getSelectedAccount'); } @@ -4412,7 +4516,7 @@ export class TransactionController extends BaseController< transactionMeta; const { networkConfigurationsByChainId } = this.#getNetworkState(); - const networkConfiguration = networkConfigurationsByChainId[chainId as Hex]; + const networkConfiguration = networkConfigurationsByChainId[chainId]; const endpoint = networkConfiguration?.rpcEndpoints.find( (currentEndpoint) => currentEndpoint.networkClientId === networkClientId, @@ -4445,7 +4549,7 @@ export class TransactionController extends BaseController< }); } - async #updateGasEstimate(transactionMeta: TransactionMeta) { + async #updateGasEstimate(transactionMeta: TransactionMeta): Promise { const { chainId, networkClientId } = transactionMeta; const isCustomNetwork = @@ -4522,7 +4626,7 @@ export class TransactionController extends BaseController< ); } - #deleteTransaction(transactionId: string) { + #deleteTransaction(transactionId: string): void { this.update((state) => { const transactions = state.transactions.filter( ({ id }) => id !== transactionId, @@ -4532,7 +4636,7 @@ export class TransactionController extends BaseController< }); } - #isRejectError(error: Error & { code?: number }) { + #isRejectError(error: Error & { code?: number }): boolean { return [ errorCodes.provider.userRejectedRequest, ErrorCode.RejectedUpgrade, @@ -4543,7 +4647,7 @@ export class TransactionController extends BaseController< transactionId: string, actionId: string | undefined, error: Error & { code?: number; data?: Json }, - ) { + ): void { this.#rejectTransaction(transactionId, actionId, error); if (error.code === errorCodes.provider.userRejectedRequest) { @@ -4560,7 +4664,7 @@ export class TransactionController extends BaseController< transactionMeta: TransactionMeta, error: Error, actionId?: string, - ) { + ): void { let newTransactionMeta: TransactionMeta; try { @@ -4580,8 +4684,8 @@ export class TransactionController extends BaseController< ).error = normalizeTxError(error); }, ); - } catch (err: unknown) { - log('Failed to mark transaction as failed', err); + } catch (caughtError: unknown) { + log('Failed to mark transaction as failed', caughtError); newTransactionMeta = { ...transactionMeta, @@ -4609,7 +4713,7 @@ export class TransactionController extends BaseController< ); } - async #runAfterSimulateHook(transactionMeta: TransactionMeta) { + async #runAfterSimulateHook(transactionMeta: TransactionMeta): Promise { log('Calling afterSimulate hook', transactionMeta); const { id: transactionId } = transactionMeta; @@ -4618,7 +4722,7 @@ export class TransactionController extends BaseController< transactionMeta, }); - const { skipSimulation, updateTransaction } = result || {}; + const { skipSimulation, updateTransaction } = result ?? {}; if (skipSimulation) { this.#skipSimulationTransactionIds.add(transactionId); @@ -4667,12 +4771,11 @@ export class TransactionController extends BaseController< ({ transactionHash } = await publishHook(transactionMeta, signedTx)); - if (transactionHash === undefined) { - transactionHash = await this.#publishTransaction(ethQuery, { - ...transactionMeta, - rawTx: signedTx, - }); - } + // eslint-disable-next-line require-atomic-updates + transactionHash ??= await this.#publishTransaction(ethQuery, { + ...transactionMeta, + rawTx: signedTx, + }); }, ); @@ -4681,7 +4784,10 @@ export class TransactionController extends BaseController< return { transactionHash }; } - async #getGasFeeTokens(transaction: TransactionMeta) { + async #getGasFeeTokens(transaction: TransactionMeta): Promise<{ + gasFeeTokens: GasFeeToken[]; + isGasFeeSponsored: boolean; + }> { const { chainId } = transaction; return await getGasFeeTokens({ @@ -4705,6 +4811,7 @@ export class TransactionController extends BaseController< const transaction = { chainId, delegationAddress, + isExternalSign: true, txParams: { data, from, diff --git a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts index 48e94d4e685..334785a8e3c 100644 --- a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts +++ b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts @@ -12,12 +12,11 @@ import { InfuraNetworkType, NetworkType, } from '@metamask/controller-utils'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MockAnyNamespace, - type MessengerActions, - type MessengerEvents, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, } from '@metamask/messenger'; import { NetworkController, @@ -28,6 +27,7 @@ import type { NetworkControllerActions, NetworkControllerEvents, NetworkClientId, + NetworkControllerOptions, } from '@metamask/network-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import assert from 'assert'; @@ -160,7 +160,13 @@ const setupController = async ( } = { selectedAccount: createMockInternalAccount({ address: '0xdeadbeef' }), }, -) => { +): Promise<{ + approvalController: ApprovalController; + messenger: Messenger; + mockGetSelectedAccount: jest.Mock; + networkController: NetworkController; + transactionController: TransactionController; +}> => { // Mainnet network must be mocked for NetworkController instantiation mockNetwork({ networkClientConfiguration: buildInfuraNetworkClientConfiguration( @@ -188,7 +194,9 @@ const setupController = async ( const networkController = new NetworkController({ messenger: networkControllerMessenger, infuraProjectId, - getRpcServiceOptions: () => ({ + getRpcServiceOptions: (): ReturnType< + NetworkControllerOptions['getRpcServiceOptions'] + > => ({ fetch, btoa, }), @@ -1181,7 +1189,7 @@ describe('TransactionController Integration', () => { ), ).rejects.toThrow( `Network client not found - ${ - networkConfiguration.rpcEndpoints[0].networkClientId as string + networkConfiguration.rpcEndpoints[0].networkClientId }`, ); @@ -1294,9 +1302,9 @@ describe('TransactionController Integration', () => { ACCOUNT_MOCK, networkClientId, ); - const delay = () => + const delay = (): Promise => // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/no-misused-promises + // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor new Promise(async (resolve) => { await advanceTime({ clock, duration: 100 }); resolve(null); @@ -1387,9 +1395,9 @@ describe('TransactionController Integration', () => { ACCOUNT_MOCK, otherSepoliaRpcEndpoint.networkClientId, ); - const delay = () => + const delay = (): Promise => // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/no-misused-promises + // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor new Promise(async (resolve) => { await advanceTime({ clock, duration: 100 }); resolve(null); diff --git a/packages/transaction-controller/src/api/accounts-api.test.ts b/packages/transaction-controller/src/api/accounts-api.test.ts index 7174de355d1..e9fd2317328 100644 --- a/packages/transaction-controller/src/api/accounts-api.test.ts +++ b/packages/transaction-controller/src/api/accounts-api.test.ts @@ -48,7 +48,10 @@ describe('Accounts API', () => { * @param status - The status code. * @returns The fetch mock. */ - function mockFetch(responseJson: Record, status = 200) { + function mockFetch( + responseJson: Record, + status = 200, + ): jest.MockedFunction { return jest.mocked(successfulFetch).mockResolvedValueOnce({ status, json: async () => responseJson, diff --git a/packages/transaction-controller/src/api/accounts-api.ts b/packages/transaction-controller/src/api/accounts-api.ts index a267eb4fb63..d8239b12e06 100644 --- a/packages/transaction-controller/src/api/accounts-api.ts +++ b/packages/transaction-controller/src/api/accounts-api.ts @@ -206,7 +206,7 @@ export async function getAccountTransactions( log('Getting account transactions', { request, url }); - const clientId = [CLIENT_ID, ...(tags || [])].join('__'); + const clientId = [CLIENT_ID, ...(tags ?? [])].join('__'); const headers = { [CLIENT_HEADER]: clientId, diff --git a/packages/transaction-controller/src/api/simulation-api.test.ts b/packages/transaction-controller/src/api/simulation-api.test.ts index 404a298e682..f3ef355e849 100644 --- a/packages/transaction-controller/src/api/simulation-api.test.ts +++ b/packages/transaction-controller/src/api/simulation-api.test.ts @@ -71,7 +71,7 @@ describe('Simulation API Utils', () => { * * @param jsonResponse - The response body to return. */ - function mockFetchResponse(jsonResponse: unknown) { + function mockFetchResponse(jsonResponse: unknown): void { fetchMock.mockResolvedValueOnce({ json: jest.fn().mockResolvedValue(jsonResponse), } as unknown as Response); diff --git a/packages/transaction-controller/src/api/simulation-api.ts b/packages/transaction-controller/src/api/simulation-api.ts index 0d67d86d4a0..96a5280dbb2 100644 --- a/packages/transaction-controller/src/api/simulation-api.ts +++ b/packages/transaction-controller/src/api/simulation-api.ts @@ -1,5 +1,6 @@ import { convertHexToDecimal } from '@metamask/controller-utils'; -import { createModuleLogger, type Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { cloneDeep } from 'lodash'; import { @@ -388,7 +389,7 @@ function finalizeRequest(request: SimulationRequest): SimulationRequest { continue; } - newRequest.overrides = newRequest.overrides || {}; + newRequest.overrides = newRequest.overrides ?? {}; newRequest.overrides[normalizedTo] = { code: CODE_DELEGATION_MANAGER_NO_SIGNATURE_ERRORS, diff --git a/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.ts index 84ae34102c4..1810023ec5a 100644 --- a/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.ts @@ -44,15 +44,11 @@ export class DefaultGasFeeFlow implements GasFeeFlow { break; case GAS_ESTIMATE_TYPES.LEGACY: log('Using legacy estimates', gasFeeEstimates); - response = this.#getLegacyEstimates( - gasFeeEstimates as LegacyGasPriceEstimate, - ); + response = this.#getLegacyEstimates(gasFeeEstimates); break; case GAS_ESTIMATE_TYPES.ETH_GASPRICE: log('Using eth_gasPrice estimates', gasFeeEstimates); - response = this.#getGasPriceEstimates( - gasFeeEstimates as EthGasPriceEstimate, - ); + response = this.#getGasPriceEstimates(gasFeeEstimates); break; default: throw new Error(`Unsupported gas estimate type: ${gasEstimateType}`); diff --git a/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts index 9f37a23d35e..ff5c308350b 100644 --- a/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts @@ -131,6 +131,7 @@ describe('LineaGasFeeFlow', () => { .mockResolvedValue(DEFAULT_RESPONSE_MOCK); const defaultGasFeeFlowGetGasFeesMock = jest.mocked( + // eslint-disable-next-line @typescript-eslint/unbound-method DefaultGasFeeFlow.prototype.getGasFees, ); @@ -151,6 +152,7 @@ describe('LineaGasFeeFlow', () => { .mockRejectedValue(new Error('TestError')); const defaultGasFeeFlowGetGasFeesMock = jest.mocked( + // eslint-disable-next-line @typescript-eslint/unbound-method DefaultGasFeeFlow.prototype.getGasFees, ); diff --git a/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts index bd9208d5699..fd01ace5834 100644 --- a/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts @@ -1,6 +1,7 @@ import { ChainId, hexToBN, query, toHex } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; -import { createModuleLogger, type Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import type BN from 'bn.js'; import { DefaultGasFeeFlow } from './DefaultGasFeeFlow'; @@ -153,7 +154,7 @@ export class LineaGasFeeFlow implements GasFeeFlow { }; } - #feesToString(fees: FeesByLevel) { + #feesToString(fees: FeesByLevel): string[] { return Object.values(GasFeeEstimateLevel).map((level) => fees[level].toString(10), ); diff --git a/packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.test.ts index bccda7b9375..37efefe4157 100644 --- a/packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.test.ts @@ -1,5 +1,6 @@ import * as ControllerUtils from '@metamask/controller-utils'; -import { hexToNumber, type Hex } from '@metamask/utils'; +import { hexToNumber } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { OptimismLayer1GasFeeFlow } from './OptimismLayer1GasFeeFlow'; import { CHAIN_IDS } from '../constants'; diff --git a/packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.ts index 0ac807cbf08..9adf12a302d 100644 --- a/packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.ts @@ -1,5 +1,6 @@ import { handleFetch } from '@metamask/controller-utils'; -import { type Hex, hexToNumber } from '@metamask/utils'; +import { hexToNumber } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { OracleLayer1GasFeeFlow } from './OracleLayer1GasFeeFlow'; import { CHAIN_IDS } from '../constants'; @@ -40,8 +41,7 @@ export class OptimismLayer1GasFeeFlow extends OracleLayer1GasFeeFlow { }): Promise { const chainIdAsNumber = hexToNumber(transactionMeta.chainId); - const supportedChains = - await OptimismLayer1GasFeeFlow.fetchOptimismSupportedChains(); + const supportedChains = await this.#fetchOptimismSupportedChains(); if (supportedChains?.has(chainIdAsNumber)) { return true; @@ -57,7 +57,7 @@ export class OptimismLayer1GasFeeFlow extends OracleLayer1GasFeeFlow { * * @returns A set of supported OP-stack chain IDs or null on failure. */ - private static async fetchOptimismSupportedChains(): Promise | null> { + async #fetchOptimismSupportedChains(): Promise | null> { try { const res: SupportedNetworksResponse = await handleFetch( GAS_SUPPORTED_NETWORKS_ENDPOINT, diff --git a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts index 3b1c80d35b6..dcfbd35e0a9 100644 --- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts @@ -2,17 +2,15 @@ import type { TypedTransaction } from '@ethereumjs/tx'; import { TransactionFactory } from '@ethereumjs/tx'; import { Contract } from '@ethersproject/contracts'; import type { Provider } from '@metamask/network-controller'; -import { add0x, type Hex } from '@metamask/utils'; +import { add0x } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import BN from 'bn.js'; import { OracleLayer1GasFeeFlow } from './OracleLayer1GasFeeFlow'; import { CHAIN_IDS } from '../constants'; import type { TransactionControllerMessenger } from '../TransactionController'; -import { - type Layer1GasFeeFlowRequest, - type TransactionMeta, - TransactionStatus, -} from '../types'; +import { TransactionStatus } from '../types'; +import type { Layer1GasFeeFlowRequest, TransactionMeta } from '../types'; import { bnFromHex, padHexToEvenLength } from '../utils/utils'; jest.mock('@ethersproject/contracts', () => ({ @@ -52,9 +50,11 @@ const DEFAULT_GAS_PRICE_ORACLE_ADDRESS = * @param serializedBuffer - The buffer returned by the serialize method. * @returns The mock TypedTransaction object. */ -function createMockTypedTransaction(serializedBuffer: Buffer) { +function createMockTypedTransaction( + serializedBuffer: Buffer, +): jest.Mocked { const instance = { - serialize: () => serializedBuffer, + serialize: (): Buffer => serializedBuffer, sign: jest.fn(), }; @@ -187,6 +187,7 @@ describe('OracleLayer1GasFeeFlow', () => { layer1Fee: LAYER_1_FEE_MOCK, }); + // eslint-disable-next-line @typescript-eslint/unbound-method expect(typedTransactionMock.sign).toHaveBeenCalledTimes(1); expect(contractGetOperatorFeeMock).not.toHaveBeenCalled(); }); @@ -235,6 +236,7 @@ describe('OracleLayer1GasFeeFlow', () => { expect(contractMock).toHaveBeenCalledTimes(1); const [oracleAddress] = contractMock.mock.calls[0]; expect(oracleAddress).toBe(DEFAULT_GAS_PRICE_ORACLE_ADDRESS); + // eslint-disable-next-line @typescript-eslint/unbound-method expect(typedTransactionMock.sign).not.toHaveBeenCalled(); }); @@ -264,7 +266,7 @@ describe('OracleLayer1GasFeeFlow', () => { .add(bnFromHex(OPERATOR_FEE_MOCK)) .toString(16), ), - ) as Hex, + ), }); }); diff --git a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts index 78d986a2bc1..bd7b9a12606 100644 --- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts @@ -1,5 +1,6 @@ import { Contract } from '@ethersproject/contracts'; -import { Web3Provider, type ExternalProvider } from '@ethersproject/providers'; +import { Web3Provider } from '@ethersproject/providers'; +import type { ExternalProvider } from '@ethersproject/providers'; import type { Hex } from '@metamask/utils'; import { add0x, createModuleLogger } from '@metamask/utils'; import BN from 'bn.js'; @@ -128,7 +129,7 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { return { layer1Fee: add0x( padHexToEvenLength(oracleFee.add(operatorFee).toString(16)), - ) as Hex, + ), }; } catch (error) { log('Failed to get oracle layer 1 gas fee', error); @@ -181,13 +182,14 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { #buildUnserializedTransaction( transactionMeta: TransactionMeta, sign: boolean, - ) { + ): ReturnType { const txParams = this.#buildTransactionParams(transactionMeta); const { chainId } = transactionMeta; let unserializedTransaction = prepareTransaction(chainId, txParams); if (sign) { + // eslint-disable-next-line no-restricted-globals const keyBuffer = Buffer.from(DUMMY_KEY, 'hex'); const keyBytes = Uint8Array.from(keyBuffer); unserializedTransaction = unserializedTransaction.sign(keyBytes); @@ -208,7 +210,7 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { #getGasPriceOracleContract( provider: Layer1GasFeeFlowRequest['provider'], chainId: Hex, - ) { + ): Contract { return new Contract( this.getOracleAddressForChain(chainId), GAS_PRICE_ORACLE_ABI, diff --git a/packages/transaction-controller/src/gas-flows/RandomisedEstimationsGasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/RandomisedEstimationsGasFeeFlow.test.ts index 53bfdbe38e6..35536bc72d4 100644 --- a/packages/transaction-controller/src/gas-flows/RandomisedEstimationsGasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/RandomisedEstimationsGasFeeFlow.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/unbound-method */ + import { toHex } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; diff --git a/packages/transaction-controller/src/gas-flows/RandomisedEstimationsGasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/RandomisedEstimationsGasFeeFlow.ts index b9b0391c2b6..5b6fa1bf9e0 100644 --- a/packages/transaction-controller/src/gas-flows/RandomisedEstimationsGasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/RandomisedEstimationsGasFeeFlow.ts @@ -1,6 +1,7 @@ import type { GasFeeEstimates as FeeMarketGasPriceEstimate } from '@metamask/gas-fee-controller'; import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; -import { add0x, createModuleLogger, type Hex } from '@metamask/utils'; +import { add0x, createModuleLogger } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { DefaultGasFeeFlow } from './DefaultGasFeeFlow'; import { projectLogger } from '../logger'; @@ -183,7 +184,7 @@ export function randomiseDecimalGWEIAndConvertToHex( // Handle the case when the value is 0 or too small if (Number(weiDecimalValue) === 0 || effectiveDigitsToRandomise <= 0) { - return `0x${Number(weiDecimalValue).toString(16)}` as Hex; + return `0x${Number(weiDecimalValue).toString(16)}` as const; } // Use string manipulation to get the base part (significant digits) diff --git a/packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.test.ts index ad3c6587659..6feae09577e 100644 --- a/packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.test.ts @@ -20,11 +20,11 @@ const TRANSACTION_META_MOCK: TransactionMeta = { describe('ScrollLayer1GasFeeFlow', () => { class TestableScrollLayer1GasFeeFlow extends ScrollLayer1GasFeeFlow { - exposeOracleAddress(chainId: Hex) { + exposeOracleAddress(chainId: Hex): Hex { return super.getOracleAddressForChain(chainId); } - exposeShouldSignTransaction() { + exposeShouldSignTransaction(): boolean { return super.shouldSignTransaction(); } } diff --git a/packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.ts index d6e65dada22..352d2c7473e 100644 --- a/packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.ts @@ -1,4 +1,4 @@ -import { type Hex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { OracleLayer1GasFeeFlow } from './OracleLayer1GasFeeFlow'; import { CHAIN_IDS } from '../constants'; diff --git a/packages/transaction-controller/src/gas-flows/TestGasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/TestGasFeeFlow.test.ts index 44a5f77c75d..68cceadf0a4 100644 --- a/packages/transaction-controller/src/gas-flows/TestGasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/TestGasFeeFlow.test.ts @@ -1,5 +1,6 @@ import { TestGasFeeFlow } from './TestGasFeeFlow'; -import { GasFeeEstimateType, type GasFeeFlowRequest } from '../types'; +import { GasFeeEstimateType } from '../types'; +import type { GasFeeFlowRequest } from '../types'; describe('TestGasFeeFlow', () => { describe('matchesTransaction', () => { diff --git a/packages/transaction-controller/src/gas-flows/TestGasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/TestGasFeeFlow.ts index 1c5d63dce8a..95bfb1212de 100644 --- a/packages/transaction-controller/src/gas-flows/TestGasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/TestGasFeeFlow.ts @@ -1,11 +1,11 @@ import { toHex } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; -import { - GasFeeEstimateType, - type GasFeeFlow, - type GasFeeFlowRequest, - type GasFeeFlowResponse, +import { GasFeeEstimateType } from '../types'; +import type { + GasFeeFlow, + GasFeeFlowRequest, + GasFeeFlowResponse, } from '../types'; const INCREMENT = 1e15; // 0.001 ETH diff --git a/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.test.ts b/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.test.ts index e433c7e3def..cf075ccc51f 100644 --- a/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.test.ts +++ b/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.test.ts @@ -8,7 +8,8 @@ import type { TransactionResponse, } from '../api/accounts-api'; import { getAccountTransactions } from '../api/accounts-api'; -import { TransactionType, type RemoteTransactionSourceRequest } from '../types'; +import { TransactionType } from '../types'; +import type { RemoteTransactionSourceRequest } from '../types'; jest.mock('../api/accounts-api'); jest.mock('../utils/transaction-type'); diff --git a/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts b/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts index c64776ddf6c..cd2bb3bac8e 100644 --- a/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts +++ b/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts @@ -111,7 +111,7 @@ export class AccountsApiRemoteTransactionSource #filterTransactions( request: RemoteTransactionSourceRequest, transactions: TransactionMeta[], - ) { + ): TransactionMeta[] { const { address, includeTokenTransfers, updateTransactions } = request; let filteredTransactions = transactions; @@ -136,7 +136,7 @@ export class AccountsApiRemoteTransactionSource responseTransaction: GetAccountTransactionsResponse['data'][0], ): Promise { const blockNumber = String(responseTransaction.blockNumber); - const chainId = `0x${responseTransaction.chainId.toString(16)}` as Hex; + const chainId = `0x${responseTransaction.chainId.toString(16)}` as const; const { hash } = responseTransaction; const time = new Date(responseTransaction.timestamp).getTime(); const id = random({ msecs: time }); diff --git a/packages/transaction-controller/src/helpers/GasFeePoller.test.ts b/packages/transaction-controller/src/helpers/GasFeePoller.test.ts index dbc859c0cdd..1f3d5aaa9cc 100644 --- a/packages/transaction-controller/src/helpers/GasFeePoller.test.ts +++ b/packages/transaction-controller/src/helpers/GasFeePoller.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-new */ import EthQuery from '@metamask/eth-query'; import type { Provider } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; @@ -10,21 +11,19 @@ import { import { flushPromises } from '../../../../tests/helpers'; import { DefaultGasFeeFlow } from '../gas-flows/DefaultGasFeeFlow'; import type { TransactionControllerMessenger } from '../TransactionController'; -import type { - GasFeeFlowResponse, - Layer1GasFeeFlow, - TransactionBatchMeta, -} from '../types'; import { GasFeeEstimateLevel, GasFeeEstimateType, TransactionEnvelopeType, TransactionStatus, UserFeeLevel, - type GasFeeFlow, - type GasFeeEstimates, - type TransactionMeta, } from '../types'; +import type { + GasFeeFlowResponse, + Layer1GasFeeFlow, + TransactionBatchMeta, +} from '../types'; +import type { GasFeeFlow, GasFeeEstimates, TransactionMeta } from '../types'; import { getTransactionLayer1GasFee } from '../utils/layer1-gas-fee-flow'; jest.mock('../utils/feature-flags'); @@ -163,10 +162,10 @@ describe('GasFeePoller', () => { getTransactionBatches: getTransactionBatchesMock, layer1GasFeeFlows: layer1GasFeeFlowsMock, messenger: messengerMock, - onStateChange: (listener: () => void) => { + onStateChange: (listener: () => void): void => { triggerOnStateChange = listener; }, - getProvider: () => ({}) as Provider, + getProvider: (): jest.Mocked => ({}) as jest.Mocked, }; }); diff --git a/packages/transaction-controller/src/helpers/GasFeePoller.ts b/packages/transaction-controller/src/helpers/GasFeePoller.ts index 54a9ddbc69b..5d370640708 100644 --- a/packages/transaction-controller/src/helpers/GasFeePoller.ts +++ b/packages/transaction-controller/src/helpers/GasFeePoller.ts @@ -17,10 +17,7 @@ import type { GasFeeEstimates, GasFeeFlow, GasFeeFlowRequest, - GasPriceGasFeeEstimates, - FeeMarketGasFeeEstimates, Layer1GasFeeFlow, - LegacyGasFeeEstimates, TransactionMeta, TransactionParams, TransactionBatchMeta, @@ -130,7 +127,7 @@ export class GasFeePoller { }); } - #start() { + #start(): void { if (this.#running) { return; } @@ -144,7 +141,7 @@ export class GasFeePoller { log('Started polling'); } - #stop() { + #stop(): void { if (!this.#running) { return; } @@ -157,7 +154,7 @@ export class GasFeePoller { log('Stopped polling'); } - async #onTimeout() { + async #onTimeout(): Promise { await this.#updateUnapprovedTransactions(); await this.#updateUnapprovedTransactionBatches(); @@ -165,7 +162,7 @@ export class GasFeePoller { this.#timeout = setTimeout(() => this.#onTimeout(), INTERVAL_MILLISECONDS); } - async #updateUnapprovedTransactions() { + async #updateUnapprovedTransactions(): Promise { const unapprovedTransactions = this.#getUnapprovedTransactions(); if (!unapprovedTransactions.length) { @@ -193,7 +190,7 @@ export class GasFeePoller { ); } - async #updateUnapprovedTransactionBatches() { + async #updateUnapprovedTransactionBatches(): Promise { const unapprovedTransactionBatches = this.#getUnapprovedTransactionBatches(); @@ -231,7 +228,7 @@ export class GasFeePoller { async #updateUnapprovedTransaction( transactionMeta: TransactionMeta, gasFeeControllerData: GasFeeState, - ) { + ): Promise { const { id } = transactionMeta; const [gasFeeEstimatesResponse, layer1GasFee] = await Promise.all([ @@ -257,7 +254,7 @@ export class GasFeePoller { async #updateUnapprovedTransactionBatch( txBatchMeta: TransactionBatchMeta, gasFeeControllerData: GasFeeState, - ) { + ): Promise { const { id } = txBatchMeta; const ethQuery = new EthQuery( @@ -374,13 +371,13 @@ export class GasFeePoller { return layer1GasFee; } - #getUnapprovedTransactions() { + #getUnapprovedTransactions(): TransactionMeta[] { return this.#getTransactions().filter( (tx) => tx.status === TransactionStatus.unapproved, ); } - #getUnapprovedTransactionBatches() { + #getUnapprovedTransactionBatches(): TransactionBatchMeta[] { return this.#getTransactionBatches().filter( (batch) => batch.status === TransactionStatus.unapproved, ); @@ -528,8 +525,7 @@ function updateGasFeeParameters( if (isEIP1559Compatible) { // Handle EIP-1559 compatible transactions if (gasEstimateType === GasFeeEstimateType.FeeMarket) { - const feeMarketGasFeeEstimates = - gasFeeEstimates as FeeMarketGasFeeEstimates; + const feeMarketGasFeeEstimates = gasFeeEstimates; txParams.maxFeePerGas = feeMarketGasFeeEstimates[userFeeLevel]?.maxFeePerGas; txParams.maxPriorityFeePerGas = @@ -537,14 +533,13 @@ function updateGasFeeParameters( } if (gasEstimateType === GasFeeEstimateType.GasPrice) { - const gasPriceGasFeeEstimates = - gasFeeEstimates as GasPriceGasFeeEstimates; + const gasPriceGasFeeEstimates = gasFeeEstimates; txParams.maxFeePerGas = gasPriceGasFeeEstimates.gasPrice; txParams.maxPriorityFeePerGas = gasPriceGasFeeEstimates.gasPrice; } if (gasEstimateType === GasFeeEstimateType.Legacy) { - const legacyGasFeeEstimates = gasFeeEstimates as LegacyGasFeeEstimates; + const legacyGasFeeEstimates = gasFeeEstimates; const gasPrice = legacyGasFeeEstimates[userFeeLevel]; txParams.maxFeePerGas = gasPrice; txParams.maxPriorityFeePerGas = gasPrice; @@ -555,19 +550,17 @@ function updateGasFeeParameters( } else { // Handle non-EIP-1559 transactions if (gasEstimateType === GasFeeEstimateType.FeeMarket) { - const feeMarketGasFeeEstimates = - gasFeeEstimates as FeeMarketGasFeeEstimates; + const feeMarketGasFeeEstimates = gasFeeEstimates; txParams.gasPrice = feeMarketGasFeeEstimates[userFeeLevel]?.maxFeePerGas; } if (gasEstimateType === GasFeeEstimateType.GasPrice) { - const gasPriceGasFeeEstimates = - gasFeeEstimates as GasPriceGasFeeEstimates; + const gasPriceGasFeeEstimates = gasFeeEstimates; txParams.gasPrice = gasPriceGasFeeEstimates.gasPrice; } if (gasEstimateType === GasFeeEstimateType.Legacy) { - const legacyGasFeeEstimates = gasFeeEstimates as LegacyGasFeeEstimates; + const legacyGasFeeEstimates = gasFeeEstimates; txParams.gasPrice = legacyGasFeeEstimates[userFeeLevel]; } diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts index 10f911952a7..9cd2f9ecf97 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts @@ -3,12 +3,8 @@ import type { Hex } from '@metamask/utils'; import { IncomingTransactionHelper } from './IncomingTransactionHelper'; import type { TransactionControllerMessenger } from '..'; import { flushPromises } from '../../../../tests/helpers'; -import { - TransactionStatus, - TransactionType, - type RemoteTransactionSource, - type TransactionMeta, -} from '../types'; +import { TransactionStatus, TransactionType } from '../types'; +import type { RemoteTransactionSource, TransactionMeta } from '../types'; import { getIncomingTransactionsPollingInterval } from '../utils/feature-flags'; jest.useFakeTimers(); @@ -98,7 +94,10 @@ const createRemoteTransactionSourceMock = ( async function runInterval( helper: IncomingTransactionHelper, { start, error }: { start?: boolean; error?: boolean } = {}, -) { +): Promise<{ + transactions: TransactionMeta[]; + incomingTransactionsListener: jest.Mock; +}> { const incomingTransactionsListener = jest.fn(); if (error) { @@ -209,7 +208,7 @@ describe('IncomingTransactionHelper', () => { it('excluding duplicates already in local transactions', async () => { const helper = new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, - getLocalTransactions: () => [TRANSACTION_MOCK], + getLocalTransactions: (): TransactionMeta[] => [TRANSACTION_MOCK], remoteTransactionSource: createRemoteTransactionSourceMock([ TRANSACTION_MOCK, TRANSACTION_MOCK_2, @@ -229,7 +228,7 @@ describe('IncomingTransactionHelper', () => { const helper = new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, - getLocalTransactions: () => [localTransaction], + getLocalTransactions: (): TransactionMeta[] => [localTransaction], remoteTransactionSource: createRemoteTransactionSourceMock([ TRANSACTION_MOCK, TRANSACTION_MOCK_2, @@ -305,7 +304,7 @@ describe('IncomingTransactionHelper', () => { it('does not if no unique transactions', async () => { const helper = new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, - getLocalTransactions: () => [TRANSACTION_MOCK], + getLocalTransactions: (): TransactionMeta[] => [TRANSACTION_MOCK], remoteTransactionSource: createRemoteTransactionSourceMock([ TRANSACTION_MOCK, ]), @@ -321,7 +320,7 @@ describe('IncomingTransactionHelper', () => { it('does not if all unique transactions are truncated', async () => { const helper = new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, - trimTransactions: () => [], + trimTransactions: (): TransactionMeta[] => [], remoteTransactionSource: createRemoteTransactionSourceMock([ TRANSACTION_MOCK, ]), @@ -368,7 +367,7 @@ describe('IncomingTransactionHelper', () => { it('does nothing if disabled', async () => { const helper = new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, - isEnabled: () => false, + isEnabled: (): boolean => false, remoteTransactionSource: createRemoteTransactionSourceMock([]), }); @@ -449,7 +448,7 @@ describe('IncomingTransactionHelper', () => { const helper = new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, - getLocalTransactions: () => [localTransaction], + getLocalTransactions: (): TransactionMeta[] => [localTransaction], remoteTransactionSource: createRemoteTransactionSourceMock([ remoteTransaction, ]), @@ -477,7 +476,7 @@ describe('IncomingTransactionHelper', () => { }; const helper = new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, - getLocalTransactions: () => [localTransaction], + getLocalTransactions: (): TransactionMeta[] => [localTransaction], remoteTransactionSource: createRemoteTransactionSourceMock([ remoteTransaction, ]), diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index 6e30cbb6fb7..180fc3dd034 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -88,7 +88,7 @@ export class IncomingTransactionHelper { this.#getCurrentAccount = getCurrentAccount; this.#getLocalTransactions = getLocalTransactions; this.#includeTokenTransfers = includeTokenTransfers; - this.#isEnabled = isEnabled ?? (() => true); + this.#isEnabled = isEnabled ?? ((): boolean => true); this.#isRunning = false; this.#isUpdating = false; this.#messenger = messenger; @@ -97,7 +97,7 @@ export class IncomingTransactionHelper { this.#updateTransactions = updateTransactions; } - start() { + start(): void { if (this.#isRunning) { return; } @@ -121,7 +121,7 @@ export class IncomingTransactionHelper { }); } - stop() { + stop(): void { if (this.#timeoutId) { clearTimeout(this.#timeoutId as number); } @@ -135,7 +135,7 @@ export class IncomingTransactionHelper { log('Stopped polling'); } - async #onInterval() { + async #onInterval(): Promise { this.#isUpdating = true; try { @@ -250,7 +250,7 @@ export class IncomingTransactionHelper { this.hub.emit('transactions', newTransactions); } - #sortTransactionsByTime(transactions: TransactionMeta[]) { + #sortTransactionsByTime(transactions: TransactionMeta[]): void { transactions.sort((a, b) => (a.time < b.time ? -1 : 1)); } diff --git a/packages/transaction-controller/src/helpers/MethodDataHelper.test.ts b/packages/transaction-controller/src/helpers/MethodDataHelper.test.ts index d922560d55e..4123a0764dd 100644 --- a/packages/transaction-controller/src/helpers/MethodDataHelper.test.ts +++ b/packages/transaction-controller/src/helpers/MethodDataHelper.test.ts @@ -22,7 +22,7 @@ const METHOD_DATA_MOCK: MethodData = { * * @returns The mocked MethodRegistry instance. */ -function createMethodRegistryMock() { +function createMethodRegistryMock(): jest.Mocked { return { lookup: jest.fn(), parse: jest.fn(), @@ -40,7 +40,9 @@ describe('MethodDataHelper', () => { it('returns method data from cache', async () => { const methodDataHelper = new MethodDataHelper({ getProvider: jest.fn(), - getState: () => ({ [FOUR_BYTE_PREFIX_MOCK]: METHOD_DATA_MOCK }), + getState: (): Record => ({ + [FOUR_BYTE_PREFIX_MOCK]: METHOD_DATA_MOCK, + }), }); const result = await methodDataHelper.lookup( @@ -62,7 +64,7 @@ describe('MethodDataHelper', () => { const methodDataHelper = new MethodDataHelper({ getProvider: jest.fn(), - getState: () => ({}), + getState: (): Record => ({}), }); const result = await methodDataHelper.lookup( @@ -81,7 +83,7 @@ describe('MethodDataHelper', () => { const methodDataHelper = new MethodDataHelper({ getProvider: jest.fn(), - getState: () => ({}), + getState: (): Record => ({}), }); const result = await methodDataHelper.lookup( @@ -105,7 +107,7 @@ describe('MethodDataHelper', () => { const methodDataHelper = new MethodDataHelper({ getProvider: getProviderMock, - getState: () => ({}), + getState: (): Record => ({}), }); await methodDataHelper.lookup( @@ -138,7 +140,7 @@ describe('MethodDataHelper', () => { const methodDataHelper = new MethodDataHelper({ getProvider: jest.fn(), - getState: () => ({}), + getState: (): Record => ({}), }); const updateListener = jest.fn(); diff --git a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts index 959536acdc5..44bed65ddf5 100644 --- a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts +++ b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts @@ -1,17 +1,25 @@ +/* eslint-disable @typescript-eslint/unbound-method */ import { ChainId, NetworkType } from '@metamask/controller-utils'; -import type { NetworkClientId, Provider } from '@metamask/network-controller'; +import type { + NetworkClientId, + NetworkState, + Provider, +} from '@metamask/network-controller'; import type { NonceTracker } from '@metamask/nonce-tracker'; import type { Hex } from '@metamask/utils'; import { useFakeTimers } from 'sinon'; -import { MultichainTrackingHelper } from './MultichainTrackingHelper'; +import { + MultichainTrackingHelper, + MultichainTrackingHelperOptions, +} from './MultichainTrackingHelper'; import type { PendingTransactionTracker } from './PendingTransactionTracker'; import { advanceTime } from '../../../../tests/helpers'; jest.mock( '@metamask/eth-query', () => - function (provider: Provider) { + function (provider: Provider): { provider: Provider } { return { provider }; }, ); @@ -22,7 +30,9 @@ jest.mock( * @param networkClientId - The network client ID to use for the mock provider. * @returns The mock provider object. */ -function buildMockProvider(networkClientId: NetworkClientId) { +function buildMockProvider(networkClientId: NetworkClientId): { + mockProvider: NetworkClientId; +} { return { mockProvider: networkClientId, }; @@ -34,7 +44,9 @@ function buildMockProvider(networkClientId: NetworkClientId) { * @param networkClientId - The network client ID to use for the mock block tracker. * @returns The mock block tracker object. */ -function buildMockBlockTracker(networkClientId: NetworkClientId) { +function buildMockBlockTracker(networkClientId: NetworkClientId): { + mockBlockTracker: NetworkClientId; +} { return { mockBlockTracker: networkClientId, }; @@ -64,7 +76,16 @@ const MOCK_PROVIDERS = { function newMultichainTrackingHelper( // eslint-disable-next-line @typescript-eslint/no-explicit-any opts: any = {}, -) { +): { + helper: MultichainTrackingHelper; + options: jest.Mocked; + mockNonceLock: { releaseLock: jest.Mock }; + mockNonceTrackers: Record>; + mockPendingTransactionTrackers: Record< + Hex, + jest.Mocked + >; +} { const mockGetNetworkClientById = jest .fn() .mockImplementation((networkClientId) => { @@ -217,8 +238,7 @@ describe('MultichainTrackingHelper', () => { it('refreshes the tracking map', () => { const { options, helper } = newMultichainTrackingHelper(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (options.onNetworkStateChange as any).mock.calls[0][0]({}, []); + options.onNetworkStateChange.mock.calls[0][0]({} as NetworkState, []); expect(options.getNetworkClientRegistry).toHaveBeenCalledTimes(1); expect(helper.has('mainnet')).toBe(true); @@ -230,8 +250,7 @@ describe('MultichainTrackingHelper', () => { it('refreshes the tracking map and excludes removed networkClientIds in the patches', () => { const { options, helper } = newMultichainTrackingHelper(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (options.onNetworkStateChange as any).mock.calls[0][0]({}, [ + options.onNetworkStateChange.mock.calls[0][0]({} as NetworkState, [ { op: 'remove', path: ['networkConfigurations', 'mainnet'], @@ -431,8 +450,8 @@ describe('MultichainTrackingHelper', () => { let error = ''; try { await helper.getNonceLock('0xdeadbeef', 'mainnet'); - } catch (err: unknown) { - error = err as string; + } catch (caughtError: unknown) { + error = caughtError as string; } expect(error).toBe('failed to acquire lock from nonceTracker'); expect(releaseLockForChainIdKey).toHaveBeenCalled(); @@ -470,9 +489,9 @@ describe('MultichainTrackingHelper', () => { key: 'a', }); - const delay = () => + const delay = (): Promise => // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/no-misused-promises + // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor new Promise(async (resolve) => { await advanceTime({ clock, duration: 100 }); resolve(null); diff --git a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts index 002038ab77c..e4060ad4804 100644 --- a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts +++ b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts @@ -114,7 +114,7 @@ export class MultichainTrackingHelper { }); } - initialize() { + initialize(): void { const networkClients = this.#getNetworkClientRegistry(); this.#refreshTrackingMap(networkClients); @@ -122,7 +122,7 @@ export class MultichainTrackingHelper { log('Initialized'); } - has(networkClientId: NetworkClientId) { + has(networkClientId: NetworkClientId): boolean { return this.#trackingMap.has(networkClientId); } @@ -178,9 +178,7 @@ export class MultichainTrackingHelper { if (!nonceTracker) { throw new Error( - `Missing nonce tracker for network client ID - ${ - networkClientId as string - }`, + `Missing nonce tracker for network client ID - ${networkClientId}`, ); } @@ -191,7 +189,7 @@ export class MultichainTrackingHelper { try { const nonceLock = await nonceTracker.getNonceLock(address); - const releaseLock = () => { + const releaseLock = (): void => { nonceLock.releaseLock(); releaseLockForChainIdKey?.(); }; @@ -200,19 +198,19 @@ export class MultichainTrackingHelper { ...nonceLock, releaseLock, }; - } catch (err) { + } catch (error) { releaseLockForChainIdKey?.(); - throw err; + throw error; } } - checkForPendingTransactionAndStartPolling = () => { + checkForPendingTransactionAndStartPolling = (): void => { for (const [, trackers] of this.#trackingMap) { trackers.pendingTransactionTracker.startIfPendingTransactions(); } }; - stopAllTracking() { + stopAllTracking(): void { for (const [networkClientId] of this.#trackingMap) { this.#stopTrackingByNetworkClientId(networkClientId); } @@ -257,7 +255,9 @@ export class MultichainTrackingHelper { }; } - readonly #refreshTrackingMap = (networkClients: NetworkClientRegistry) => { + readonly #refreshTrackingMap = ( + networkClients: NetworkClientRegistry, + ): void => { const networkClientIds = Object.keys(networkClients); const existingNetworkClientIds = Array.from(this.#trackingMap.keys()); @@ -288,7 +288,7 @@ export class MultichainTrackingHelper { } }; - #stopTrackingByNetworkClientId(networkClientId: NetworkClientId) { + #stopTrackingByNetworkClientId(networkClientId: NetworkClientId): void { const trackers = this.#trackingMap.get(networkClientId); if (trackers) { trackers.pendingTransactionTracker.stop(); @@ -299,7 +299,7 @@ export class MultichainTrackingHelper { } } - #startTrackingByNetworkClientId(networkClientId: NetworkClientId) { + #startTrackingByNetworkClientId(networkClientId: NetworkClientId): void { const trackers = this.#trackingMap.get(networkClientId); if (trackers) { return; diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts index a24e93f9e57..eefdbb8ab24 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/unbound-method */ import { query } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; import type { BlockTracker } from '@metamask/network-controller'; @@ -45,9 +46,6 @@ jest.mock('./TransactionPoller'); jest.mock('@metamask/controller-utils', () => ({ query: jest.fn(), - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - safelyExecute: (fn: () => any) => fn(), })); /** @@ -108,7 +106,7 @@ describe('PendingTransactionTracker', () => { async function onPoll( latestBlockNumber?: string, transactionsOnCheck?: TransactionMeta[], - ) { + ): Promise { options.getTransactions.mockReturnValue([ { ...TRANSACTION_SUBMITTED_MOCK }, ]); @@ -266,7 +264,7 @@ describe('PendingTransactionTracker', () => { pendingTransactionTracker = new PendingTransactionTracker({ ...options, - getTransactions: () => + getTransactions: (): TransactionMeta[] => freeze([{ ...TRANSACTION_SUBMITTED_MOCK }], true), }); @@ -296,7 +294,7 @@ describe('PendingTransactionTracker', () => { pendingTransactionTracker = new PendingTransactionTracker({ ...options, - getTransactions: () => + getTransactions: (): TransactionMeta[] => freeze([{ ...TRANSACTION_SUBMITTED_MOCK }], true), }); @@ -326,7 +324,7 @@ describe('PendingTransactionTracker', () => { pendingTransactionTracker = new PendingTransactionTracker({ ...options, - getTransactions: () => + getTransactions: (): TransactionMeta[] => freeze([{ ...TRANSACTION_SUBMITTED_MOCK }], true), }); @@ -363,7 +361,8 @@ describe('PendingTransactionTracker', () => { pendingTransactionTracker = new PendingTransactionTracker({ ...options, - getTransactions: () => freeze([transactionMetaMock], true), + getTransactions: (): TransactionMeta[] => + freeze([transactionMetaMock], true), }); pendingTransactionTracker.hub.addListener( @@ -392,9 +391,11 @@ describe('PendingTransactionTracker', () => { pendingTransactionTracker = new PendingTransactionTracker({ ...options, - getTransactions: () => freeze([transactionMetaMock], true), + getTransactions: (): TransactionMeta[] => + freeze([transactionMetaMock], true), hooks: { - beforeCheckPendingTransaction: () => Promise.resolve(false), + beforeCheckPendingTransaction: (): Promise => + Promise.resolve(false), }, }); @@ -413,7 +414,7 @@ describe('PendingTransactionTracker', () => { pendingTransactionTracker = new PendingTransactionTracker({ ...options, - getTransactions: () => + getTransactions: (): TransactionMeta[] => freeze([{ ...TRANSACTION_SUBMITTED_MOCK }], true), }); @@ -450,7 +451,7 @@ describe('PendingTransactionTracker', () => { pendingTransactionTracker = new PendingTransactionTracker({ ...options, - getTransactions: () => + getTransactions: (): TransactionMeta[] => freeze( [confirmedTransactionMetaMock, submittedTransactionMetaMock], true, @@ -473,7 +474,7 @@ describe('PendingTransactionTracker', () => { pendingTransactionTracker = new PendingTransactionTracker({ ...options, - getTransactions: () => + getTransactions: (): TransactionMeta[] => freeze([{ ...TRANSACTION_SUBMITTED_MOCK }], true), }); @@ -511,7 +512,7 @@ describe('PendingTransactionTracker', () => { pendingTransactionTracker = new PendingTransactionTracker({ ...options, - getTransactions: () => [ + getTransactions: (): TransactionMeta[] => [ confirmedTransactionMetaMock, submittedTransactionMetaMock, ], @@ -550,7 +551,7 @@ describe('PendingTransactionTracker', () => { pendingTransactionTracker = new PendingTransactionTracker({ ...options, - getTransactions: () => [ + getTransactions: (): TransactionMeta[] => [ confirmedTransactionMetaMock, submittedTransactionMetaMock, ], @@ -582,7 +583,7 @@ describe('PendingTransactionTracker', () => { pendingTransactionTracker = new PendingTransactionTracker({ ...options, - getTransactions: () => [ + getTransactions: (): TransactionMeta[] => [ confirmedTransactionMetaMock, submittedTransactionMetaMock, ], @@ -780,7 +781,7 @@ describe('PendingTransactionTracker', () => { pendingTransactionTracker = new PendingTransactionTracker({ ...options, - getTransactions: () => + getTransactions: (): TransactionMeta[] => freeze([{ ...TRANSACTION_SUBMITTED_MOCK }], true), }); @@ -859,7 +860,8 @@ describe('PendingTransactionTracker', () => { ...options, getTransactions, hooks: { - beforeCheckPendingTransaction: () => Promise.resolve(false), + beforeCheckPendingTransaction: (): Promise => + Promise.resolve(false), }, }); @@ -1127,7 +1129,7 @@ describe('PendingTransactionTracker', () => { pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions, - isResubmitEnabled: () => false, + isResubmitEnabled: (): boolean => false, }); queryMock.mockResolvedValueOnce(undefined); diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts index 4d2ddd9fe16..5ede1472224 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts @@ -53,12 +53,15 @@ type Events = { // Convert to a `type` in a future major version. // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export interface PendingTransactionTrackerEventEmitter extends EventEmitter { - on( - eventName: T, - listener: (...args: Events[T]) => void, + on( + eventName: EventName, + listener: (...args: Events[EventName]) => void, ): this; - emit(eventName: T, ...args: Events[T]): boolean; + emit( + eventName: EventName, + ...args: Events[EventName] + ): boolean; } export class PendingTransactionTracker { @@ -136,7 +139,7 @@ export class PendingTransactionTracker { this.#getEthQuery = getEthQuery; this.#getNetworkClientId = getNetworkClientId; this.#getTransactions = getTransactions; - this.#isResubmitEnabled = isResubmitEnabled ?? (() => true); + this.#isResubmitEnabled = isResubmitEnabled ?? ((): boolean => true); this.#listener = this.#onLatestBlock.bind(this); this.#getGlobalLock = getGlobalLock; this.#publishTransaction = publishTransaction; @@ -152,7 +155,7 @@ export class PendingTransactionTracker { this.#beforeCheckPendingTransaction = hooks?.beforeCheckPendingTransaction ?? /* istanbul ignore next */ - (() => Promise.resolve(true)); + ((): Promise => Promise.resolve(true)); this.#log = createModuleLogger( log, @@ -160,7 +163,7 @@ export class PendingTransactionTracker { ); } - startIfPendingTransactions = () => { + startIfPendingTransactions = (): void => { const pendingTransactions = this.#getPendingTransactions(); if (pendingTransactions.length) { @@ -191,7 +194,7 @@ export class PendingTransactionTracker { * * @param txMeta - The transaction to check */ - async forceCheckTransaction(txMeta: TransactionMeta) { + async forceCheckTransaction(txMeta: TransactionMeta): Promise { const releaseLock = await this.#getGlobalLock(); try { @@ -204,7 +207,7 @@ export class PendingTransactionTracker { } } - #start(pendingTransactions: TransactionMeta[]) { + #start(pendingTransactions: TransactionMeta[]): void { this.#transactionPoller.setPendingTransactions(pendingTransactions); if (this.#running) { @@ -217,7 +220,7 @@ export class PendingTransactionTracker { this.#log('Started polling'); } - stop() { + stop(): void { if (!this.#running) { return; } @@ -228,7 +231,7 @@ export class PendingTransactionTracker { this.#log('Stopped polling'); } - async #onLatestBlock(latestBlockNumber: string) { + async #onLatestBlock(latestBlockNumber: string): Promise { const releaseLock = await this.#getGlobalLock(); try { @@ -248,7 +251,7 @@ export class PendingTransactionTracker { } } - async #checkTransactions() { + async #checkTransactions(): Promise { this.#log('Checking transactions'); const pendingTransactions: TransactionMeta[] = [ @@ -271,7 +274,7 @@ export class PendingTransactionTracker { ); } - async #resubmitTransactions(latestBlockNumber: string) { + async #resubmitTransactions(latestBlockNumber: string): Promise { if (!this.#isResubmitEnabled() || !this.#running) { return; } @@ -298,8 +301,8 @@ export class PendingTransactionTracker { } catch (error: any) { /* istanbul ignore next */ const errorMessage = - error.value?.message?.toLowerCase() || - error.message?.toLowerCase() || + error.value?.message?.toLowerCase() ?? + error.message?.toLowerCase() ?? String(error); if (this.#isKnownTransactionError(errorMessage)) { @@ -316,7 +319,7 @@ export class PendingTransactionTracker { } } - #isKnownTransactionError(errorMessage: string) { + #isKnownTransactionError(errorMessage: string): boolean { return KNOWN_TRANSACTION_ERRORS.some((knownError) => errorMessage.includes(knownError), ); @@ -325,7 +328,7 @@ export class PendingTransactionTracker { async #resubmitTransaction( txMeta: TransactionMeta, latestBlockNumber: string, - ) { + ): Promise { if (!this.#isResubmitDue(txMeta, latestBlockNumber)) { return; } @@ -363,7 +366,7 @@ export class PendingTransactionTracker { Number.parseInt(latestBlockNumber, 16) - Number.parseInt(firstRetryBlockNumber, 16); - const retryCount = txMeta.retryCount || 0; + const retryCount = txMeta.retryCount ?? 0; // Exponential backoff to limit retries at publishing // Capped at ~15 minutes between retries @@ -375,13 +378,13 @@ export class PendingTransactionTracker { return blocksSinceFirstRetry >= requiredBlocksSinceFirstRetry; } - #cleanTransactionToForcePoll(transactionId: string) { + #cleanTransactionToForcePoll(transactionId: string): void { if (this.#transactionToForcePoll?.id === transactionId) { this.#transactionToForcePoll = undefined; } } - async #checkTransaction(txMeta: TransactionMeta) { + async #checkTransaction(txMeta: TransactionMeta): Promise { const { hash, id, isIntentComplete } = txMeta; if (isIntentComplete) { @@ -423,7 +426,7 @@ export class PendingTransactionTracker { return; } - const { blockNumber, blockHash } = receipt || {}; + const { blockNumber, blockHash } = receipt ?? {}; if (isSuccess && blockNumber && blockHash) { await this.#onTransactionConfirmed(txMeta, { @@ -456,7 +459,7 @@ export class PendingTransactionTracker { async #onTransactionConfirmed( txMeta: TransactionMeta, receipt?: SuccessfulTransactionReceipt, - ) { + ): Promise { const { id } = txMeta; const { blockHash } = receipt ?? {}; @@ -494,7 +497,7 @@ export class PendingTransactionTracker { this.hub.emit('transaction-confirmed', updatedTxMeta); } - async #isTransactionDropped(txMeta: TransactionMeta) { + async #isTransactionDropped(txMeta: TransactionMeta): Promise { const { hash, id, @@ -557,7 +560,11 @@ export class PendingTransactionTracker { ); } - #warnTransaction(txMeta: TransactionMeta, error: string, message: string) { + #warnTransaction( + txMeta: TransactionMeta, + error: string, + message: string, + ): void { this.#updateTransaction( { ...txMeta, @@ -567,19 +574,19 @@ export class PendingTransactionTracker { ); } - #failTransaction(txMeta: TransactionMeta, error: Error) { + #failTransaction(txMeta: TransactionMeta, error: Error): void { this.#log('Transaction failed', txMeta.id, error); this.#cleanTransactionToForcePoll(txMeta.id); this.hub.emit('transaction-failed', txMeta, error); } - #dropTransaction(txMeta: TransactionMeta) { + #dropTransaction(txMeta: TransactionMeta): void { this.#log('Transaction dropped', txMeta.id); this.#cleanTransactionToForcePoll(txMeta.id); this.hub.emit('transaction-dropped', txMeta); } - #updateTransaction(txMeta: TransactionMeta, note: string) { + #updateTransaction(txMeta: TransactionMeta, note: string): void { this.hub.emit('transaction-updated', txMeta, note); } diff --git a/packages/transaction-controller/src/helpers/ResimulateHelper.test.ts b/packages/transaction-controller/src/helpers/ResimulateHelper.test.ts index 9c372dd4264..8d15cfc3da2 100644 --- a/packages/transaction-controller/src/helpers/ResimulateHelper.test.ts +++ b/packages/transaction-controller/src/helpers/ResimulateHelper.test.ts @@ -3,7 +3,6 @@ import type { NetworkClientId } from '@metamask/network-controller'; import { BN } from 'bn.js'; import { - type ResimulateHelperOptions, ResimulateHelper, BLOCK_TIME_ADDITIONAL_SECONDS, BLOCKAID_RESULT_TYPE_MALICIOUS, @@ -13,6 +12,7 @@ import { VALUE_COMPARISON_PERCENT_THRESHOLD, RESIMULATE_INTERVAL_MS, } from './ResimulateHelper'; +import type { ResimulateHelperOptions } from './ResimulateHelper'; import { CHAIN_IDS } from '../constants'; import type { TransactionMeta, @@ -94,7 +94,7 @@ describe('ResimulateHelper', () => { /** * Triggers onStateChange callback */ - function triggerStateChange() { + function triggerStateChange(): void { onTransactionsUpdateMock.mock.calls[0][0](); } @@ -103,7 +103,7 @@ describe('ResimulateHelper', () => { * * @param transactions - Transactions to be returned */ - function mockGetTransactionsOnce(transactions: TransactionMeta[]) { + function mockGetTransactionsOnce(transactions: TransactionMeta[]): void { getTransactionsMock.mockReturnValueOnce( transactions as unknown as ResimulateHelperOptions['getTransactions'], ); @@ -115,6 +115,7 @@ describe('ResimulateHelper', () => { onTransactionsUpdateMock = jest.fn(); simulateTransactionMock = jest.fn().mockResolvedValue(undefined); + // eslint-disable-next-line no-new new ResimulateHelper({ getTransactions: getTransactionsMock, onTransactionsUpdate: onTransactionsUpdateMock, diff --git a/packages/transaction-controller/src/helpers/ResimulateHelper.ts b/packages/transaction-controller/src/helpers/ResimulateHelper.ts index 7a3403fc800..fbe2d801bf8 100644 --- a/packages/transaction-controller/src/helpers/ResimulateHelper.ts +++ b/packages/transaction-controller/src/helpers/ResimulateHelper.ts @@ -53,7 +53,7 @@ export class ResimulateHelper { onTransactionsUpdate(this.#onTransactionsUpdate.bind(this)); } - #onTransactionsUpdate() { + #onTransactionsUpdate(): void { const unapprovedTransactions = this.#getTransactions().filter( (tx) => tx.status === TransactionStatus.unapproved, ); @@ -81,13 +81,13 @@ export class ResimulateHelper { }); } - #start(transactionMeta: TransactionMeta) { + #start(transactionMeta: TransactionMeta): void { const { id: transactionId } = transactionMeta; if (this.#timeoutIds.has(transactionId)) { return; } - const listener = () => { + const listener = (): void => { this.#simulateTransaction(transactionMeta) .catch((error) => { /* istanbul ignore next */ @@ -108,12 +108,12 @@ export class ResimulateHelper { ); } - #queueUpdate(transactionId: string, listener: () => void) { + #queueUpdate(transactionId: string, listener: () => void): void { const timeoutId = setTimeout(listener, RESIMULATE_INTERVAL_MS); this.#timeoutIds.set(transactionId, timeoutId); } - #stop(transactionId: string) { + #stop(transactionId: string): void { if (!this.#timeoutIds.has(transactionId)) { return; } @@ -124,7 +124,7 @@ export class ResimulateHelper { ); } - #removeListener(id: string) { + #removeListener(id: string): void { const timeoutId = this.#timeoutIds.get(id); if (timeoutId) { clearTimeout(timeoutId); @@ -143,7 +143,7 @@ export class ResimulateHelper { export function shouldResimulate( originalTransactionMeta: TransactionMeta, newTransactionMeta: TransactionMeta, -) { +): ResimulateResponse { const { id: transactionId } = newTransactionMeta; const parametersUpdated = isParametersUpdated( diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts index e7a85e6b5cb..000e9513924 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts @@ -25,7 +25,10 @@ const MESSENGER_MOCK = { } as unknown as jest.Mocked; jest.mock('../utils/feature-flags', () => ({ - getAcceleratedPollingParams: () => ({ + getAcceleratedPollingParams: (): { + countMax: number; + intervalMs: number; + } => ({ countMax: DEFAULT_ACCELERATED_COUNT_MAX, intervalMs: DEFAULT_ACCELERATED_POLLING_INTERVAL_MS, }), @@ -38,7 +41,7 @@ jest.mock('../utils/feature-flags', () => ({ * @param id - The transaction ID. * @returns The mock transaction metadata object. */ -function createTransactionMetaMock(id: string) { +function createTransactionMetaMock(id: string): TransactionMeta { return { id } as TransactionMeta; } @@ -217,6 +220,7 @@ describe('TransactionPoller', () => { poller.stop(); + // eslint-disable-next-line @typescript-eslint/unbound-method expect(BLOCK_TRACKER_MOCK.removeListener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledTimes(DEFAULT_ACCELERATED_COUNT_MAX); }); @@ -231,6 +235,7 @@ describe('TransactionPoller', () => { poller.stop(); expect(jest.getTimerCount()).toBe(0); + // eslint-disable-next-line @typescript-eslint/unbound-method expect(BLOCK_TRACKER_MOCK.removeListener).not.toHaveBeenCalled(); }); }); diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.ts b/packages/transaction-controller/src/helpers/TransactionPoller.ts index a6f65f9b784..7c9ad49baff 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.ts @@ -1,5 +1,6 @@ import type { BlockTracker } from '@metamask/network-controller'; -import { createModuleLogger, type Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { isEqual } from 'lodash'; import { projectLogger } from '../logger'; @@ -52,7 +53,7 @@ export class TransactionPoller { * * @param listener - The listener to call on every interval. */ - start(listener: (latestBlockNumber: string) => Promise) { + start(listener: (latestBlockNumber: string) => Promise): void { if (this.#running) { return; } @@ -69,7 +70,7 @@ export class TransactionPoller { * Stop the poller. * Remove all timeouts and block tracker listeners. */ - stop() { + stop(): void { if (!this.#running) { return; } @@ -92,7 +93,7 @@ export class TransactionPoller { * * @param pendingTransactions - The pending transactions to poll. */ - setPendingTransactions(pendingTransactions: TransactionMeta[]) { + setPendingTransactions(pendingTransactions: TransactionMeta[]): void { const currentPendingTransactionIds = (this.#pendingTransactions ?? []).map( (tx) => tx.id, ); @@ -120,7 +121,7 @@ export class TransactionPoller { } } - #queue() { + #queue(): void { if (!this.#running) { return; } @@ -132,7 +133,7 @@ export class TransactionPoller { if (this.#acceleratedCount >= countMax) { // eslint-disable-next-line @typescript-eslint/no-misused-promises - this.#blockTrackerListener = (latestBlockNumber) => + this.#blockTrackerListener = (latestBlockNumber): Promise => this.#interval(false, latestBlockNumber); this.#blockTracker.on('latest', this.#blockTrackerListener); @@ -151,7 +152,10 @@ export class TransactionPoller { }, intervalMs); } - async #interval(isAccelerated: boolean, latestBlockNumber?: string) { + async #interval( + isAccelerated: boolean, + latestBlockNumber?: string, + ): Promise { if (isAccelerated) { log('Accelerated interval', this.#acceleratedCount + 1); } else { @@ -168,7 +172,7 @@ export class TransactionPoller { } } - #stopTimeout() { + #stopTimeout(): void { if (!this.#timeout) { return; } @@ -177,7 +181,7 @@ export class TransactionPoller { this.#timeout = undefined; } - #stopBlockTracker() { + #stopBlockTracker(): void { if (!this.#blockTrackerListener) { return; } diff --git a/packages/transaction-controller/src/hooks/CollectPublishHook.ts b/packages/transaction-controller/src/hooks/CollectPublishHook.ts index 90b87d067d1..4fcd31602a7 100644 --- a/packages/transaction-controller/src/hooks/CollectPublishHook.ts +++ b/packages/transaction-controller/src/hooks/CollectPublishHook.ts @@ -51,7 +51,7 @@ export class CollectPublishHook { * * @param transactionHashes - The transaction hashes to pass to the original publish promises. */ - success(transactionHashes: Hex[]) { + success(transactionHashes: Hex[]): void { log('Success', { transactionHashes }); if (transactionHashes.length !== this.#transactionCount) { @@ -66,7 +66,7 @@ export class CollectPublishHook { } } - error(error: unknown) { + error(error: unknown): void { log('Error', { error }); for (const result of this.#results) { @@ -95,7 +95,7 @@ export class CollectPublishHook { signedTransaction: signedTx as Hex, }); - this.#results = sortBy(this.#results, (r) => r.nonce); + this.#results = sortBy(this.#results, (result) => result.nonce); if (this.#results.length === this.#transactionCount) { log('All transactions signed'); diff --git a/packages/transaction-controller/src/hooks/ExtraTransactionsPublishHook.test.ts b/packages/transaction-controller/src/hooks/ExtraTransactionsPublishHook.test.ts index b341871884c..595fde7a256 100644 --- a/packages/transaction-controller/src/hooks/ExtraTransactionsPublishHook.test.ts +++ b/packages/transaction-controller/src/hooks/ExtraTransactionsPublishHook.test.ts @@ -69,7 +69,7 @@ describe('ExtraTransactionsPublishHook', () => { * * @returns The ExtraTransactionsPublishHook instance. */ - function createHook() { + function createHook(): ExtraTransactionsPublishHook { return new ExtraTransactionsPublishHook({ addTransactionBatch: addTransactionBatchMock, getTransaction: getTransactionMock, diff --git a/packages/transaction-controller/src/hooks/ExtraTransactionsPublishHook.ts b/packages/transaction-controller/src/hooks/ExtraTransactionsPublishHook.ts index aa78dc62fef..6aaf6c5e952 100644 --- a/packages/transaction-controller/src/hooks/ExtraTransactionsPublishHook.ts +++ b/packages/transaction-controller/src/hooks/ExtraTransactionsPublishHook.ts @@ -1,8 +1,5 @@ -import { - createDeferredPromise, - createModuleLogger, - type Hex, -} from '@metamask/utils'; +import { createDeferredPromise, createModuleLogger } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import type { TransactionController } from '..'; import { projectLogger } from '../logger'; @@ -85,7 +82,7 @@ export class ExtraTransactionsPublishHook { }: { newSignature?: Hex; transactionHash?: string; - }) => { + }): void => { if (newSignature) { const latestTransactionMeta = this.#getTransaction(transactionId); diff --git a/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.test.ts b/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.test.ts index 94aa9247134..4108f56a8cb 100644 --- a/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.test.ts +++ b/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/unbound-method */ import type EthQuery from '@metamask/eth-query'; import type { Hex } from '@metamask/utils'; @@ -64,7 +65,7 @@ describe('SequentialPublishBatchHook', () => { function firePendingTransactionTrackerEvent( eventName: string, ...args: unknown[] - ) { + ): void { eventListeners[eventName]?.forEach((callback) => callback(...args)); } diff --git a/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.ts b/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.ts index bfdbb27d4d5..3e34ec6293b 100644 --- a/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.ts +++ b/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.ts @@ -5,11 +5,11 @@ import type { Hex } from '@metamask/utils'; import type { PendingTransactionTracker } from '../helpers/PendingTransactionTracker'; import { projectLogger } from '../logger'; -import { - type PublishBatchHook, - type PublishBatchHookRequest, - type PublishBatchHookResult, - type TransactionMeta, +import type { + PublishBatchHook, + PublishBatchHookRequest, + PublishBatchHookResult, + TransactionMeta, } from '../types'; const log = createModuleLogger(projectLogger, 'sequential-publish-batch-hook'); diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index a07459a3020..a339a13cdb2 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + import type { AccessList } from '@ethereumjs/tx'; import type { AccountsController } from '@metamask/accounts-controller'; import type EthQuery from '@metamask/eth-query'; @@ -752,6 +754,11 @@ export enum TransactionType { */ lendingWithdraw = 'lendingWithdraw', + /** + * A transaction that converts tokens to mUSD. + */ + musdConversion = 'musdConversion', + /** * Deposit funds to be available for trading via Perps. */ @@ -1723,6 +1730,9 @@ export type TransactionBatchRequest = { /** Whether to disable batch transaction via sequential transactions. */ disableSequential?: boolean; + /** Whether to disable upgrading the account to an EIP-7702. */ + disableUpgrade?: boolean; + /** Address of the account to submit the transaction batch. */ from: Hex; @@ -1741,12 +1751,18 @@ export type TransactionBatchRequest = { /** Origin of the request, such as a dApp hostname or `ORIGIN_METAMASK` if internal. */ origin?: string; + /** Whether to overwrite existing EIP-7702 delegation with MetaMask contract. */ + overwriteUpgrade?: boolean; + /** Whether an approval request should be created to require confirmation from the user. */ requireApproval?: boolean; /** Security alert ID to persist on the transaction. */ securityAlertId?: string; + /** Whether to skip the initial gas calculation and rely only on the polling. */ + skipInitialGasEstimate?: boolean; + /** Transactions to be submitted as part of the batch. */ transactions: TransactionBatchSingleRequest[]; @@ -2100,6 +2116,9 @@ export type AddTransactionOptions = { /** Entries to add to the `sendFlowHistory`. */ sendFlowHistory?: SendFlowHistoryEntry[]; + /** Whether to skip the initial gas calculation and rely only on the polling. */ + skipInitialGasEstimate?: boolean; + /** Options for swaps transactions. */ swaps?: { /** Whether the transaction has an approval transaction. */ diff --git a/packages/transaction-controller/src/utils/balance-changes.test.ts b/packages/transaction-controller/src/utils/balance-changes.test.ts index ed507af2796..135ded3a8dc 100644 --- a/packages/transaction-controller/src/utils/balance-changes.test.ts +++ b/packages/transaction-controller/src/utils/balance-changes.test.ts @@ -2,18 +2,16 @@ import type { LogDescription } from '@ethersproject/abi'; import { Interface } from '@ethersproject/abi'; import { query } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; -import { type Hex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import type { GetBalanceChangesRequest } from './balance-changes'; import { getBalanceChanges, SupportedToken } from './balance-changes'; +import { simulateTransactions } from '../api/simulation-api'; import type { SimulationResponseLog, SimulationResponseTransaction, } from '../api/simulation-api'; -import { - simulateTransactions, - type SimulationResponse, -} from '../api/simulation-api'; +import type { SimulationResponse } from '../api/simulation-api'; import { SimulationInvalidResponseError, SimulationRevertedError, @@ -81,7 +79,7 @@ const PARSED_ERC20_TRANSFER_EVENT_MOCK = { args: [ USER_ADDRESS_MOCK, OTHER_ADDRESS_MOCK, - { toHexString: () => VALUE_MOCK }, + { toHexString: (): Hex => VALUE_MOCK }, ], } as unknown as LogDescription; @@ -91,7 +89,7 @@ const PARSED_ERC721_TRANSFER_EVENT_MOCK = { args: [ OTHER_ADDRESS_MOCK, USER_ADDRESS_MOCK, - { toHexString: () => TOKEN_ID_MOCK }, + { toHexString: (): Hex => TOKEN_ID_MOCK }, ], } as unknown as LogDescription; @@ -102,8 +100,8 @@ const PARSED_ERC1155_TRANSFER_SINGLE_EVENT_MOCK = { OTHER_ADDRESS_MOCK, OTHER_ADDRESS_MOCK, USER_ADDRESS_MOCK, - { toHexString: () => TOKEN_ID_MOCK }, - { toHexString: () => VALUE_MOCK }, + { toHexString: (): Hex => TOKEN_ID_MOCK }, + { toHexString: (): Hex => VALUE_MOCK }, ], } as unknown as LogDescription; @@ -114,15 +112,15 @@ const PARSED_ERC1155_TRANSFER_BATCH_EVENT_MOCK = { OTHER_ADDRESS_MOCK, OTHER_ADDRESS_MOCK, USER_ADDRESS_MOCK, - [{ toHexString: () => TOKEN_ID_MOCK }], - [{ toHexString: () => VALUE_MOCK }], + [{ toHexString: (): Hex => TOKEN_ID_MOCK }], + [{ toHexString: (): Hex => VALUE_MOCK }], ], } as unknown as LogDescription; const PARSED_WRAPPED_ERC20_DEPOSIT_EVENT_MOCK = { name: 'Deposit', contractAddress: CONTRACT_ADDRESS_1_MOCK, - args: [USER_ADDRESS_MOCK, { toHexString: () => VALUE_MOCK }], + args: [USER_ADDRESS_MOCK, { toHexString: (): Hex => VALUE_MOCK }], } as unknown as LogDescription; const defaultResponseTx: SimulationResponseTransaction = { @@ -163,7 +161,7 @@ const RESPONSE_NESTED_LOGS_MOCK: SimulationResponse = { * @param contractAddress - The contract address. * @returns The raw log mock. */ -function createLogMock(contractAddress: string) { +function createLogMock(contractAddress: string): SimulationResponseLog { return { address: contractAddress, } as unknown as SimulationResponseLog; @@ -199,7 +197,7 @@ function createNativeBalanceResponse( previousBalance: string, newBalance: string, gasCost: number = 0, -) { +): SimulationResponse { return { transactions: [ { @@ -229,7 +227,7 @@ function createNativeBalanceResponse( function createBalanceOfResponse( previousBalances: string[], newBalances: string[], -) { +): SimulationResponse { return { transactions: [ ...previousBalances.map((previousBalance) => ({ @@ -267,7 +265,7 @@ function mockParseLog({ erc1155?: LogDescription; erc20Wrapped?: LogDescription; erc721Legacy?: LogDescription; -}) { +}): void { const parseLogMock = jest.spyOn(Interface.prototype, 'parseLog'); for (const value of [erc20, erc721, erc1155, erc20Wrapped, erc721Legacy]) { @@ -390,7 +388,7 @@ describe('Balance Change Utils', () => { args: [ OTHER_ADDRESS_MOCK, USER_ADDRESS_WITH_LEADING_ZERO, - { toHexString: () => TOKEN_ID_MOCK }, + { toHexString: (): Hex => TOKEN_ID_MOCK }, ], }, tokenType: SupportedToken.ERC721, @@ -789,7 +787,7 @@ describe('Balance Change Utils', () => { args: [ OTHER_ADDRESS_MOCK, OTHER_ADDRESS_MOCK, - { toHexString: () => VALUE_MOCK }, + { toHexString: (): Hex => VALUE_MOCK }, ], }, }); @@ -871,7 +869,7 @@ describe('Balance Change Utils', () => { // Contract returns 64 extra zeros in raw output of balanceOf. // Abi decoding should ignore them. - const encodeOutputWith64ExtraZeros = (value: string) => + const encodeOutputWith64ExtraZeros = (value: string): Hex => (encodeTo32ByteHex(value) + ''.padStart(64, '0')) as Hex; const RAW_BALANCE_BEFORE = encodeOutputWith64ExtraZeros( DECODED_BALANCE_BEFORE, diff --git a/packages/transaction-controller/src/utils/balance-changes.ts b/packages/transaction-controller/src/utils/balance-changes.ts index a49404535a9..0820e97c369 100644 --- a/packages/transaction-controller/src/utils/balance-changes.ts +++ b/packages/transaction-controller/src/utils/balance-changes.ts @@ -3,7 +3,8 @@ import { Interface } from '@ethersproject/abi'; import { hexToBN, toHex } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; import { abiERC20, abiERC721, abiERC1155 } from '@metamask/metamask-eth-abis'; -import { createModuleLogger, type Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import BN from 'bn.js'; import { getNativeBalance } from './balance'; @@ -41,7 +42,9 @@ export enum SupportedToken { ERC20 = 'erc20', ERC721 = 'erc721', ERC1155 = 'erc1155', + // eslint-disable-next-line @typescript-eslint/naming-convention ERC20_WRAPPED = 'erc20Wrapped', + // eslint-disable-next-line @typescript-eslint/naming-convention ERC721_LEGACY = 'erc721Legacy', } @@ -236,7 +239,9 @@ function getEvents(response: SimulationResponse): ParsedEvent[] { } /* istanbul ignore next */ - const inputs = event.abi.find((e) => e.name === event.name)?.inputs; + const inputs = event.abi.find( + (eventAbi) => eventAbi.name === event.name, + )?.inputs; /* istanbul ignore if */ if (!inputs) { @@ -261,7 +266,7 @@ function getEvents(response: SimulationResponse): ParsedEvent[] { abi: event.abi, }; }) - .filter((e) => e !== undefined) as ParsedEvent[]; + .filter((parsedEvent) => parsedEvent !== undefined) as ParsedEvent[]; } /** @@ -598,9 +603,7 @@ function parseLog( abi, standard, }; - // Not used - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { + } catch { continue; } } diff --git a/packages/transaction-controller/src/utils/balance.ts b/packages/transaction-controller/src/utils/balance.ts index 1a362d60c36..bea4270ccfa 100644 --- a/packages/transaction-controller/src/utils/balance.ts +++ b/packages/transaction-controller/src/utils/balance.ts @@ -12,7 +12,13 @@ import type { TransactionMeta } from '..'; * @param ethQuery - EthQuery instance to use. * @returns Balance in both human-readable and raw format. */ -export async function getNativeBalance(address: Hex, ethQuery: EthQuery) { +export async function getNativeBalance( + address: Hex, + ethQuery: EthQuery, +): Promise<{ + balanceHuman: string; + balanceRaw: string; +}> { const balanceRawHex = (await query(ethQuery, 'getBalance', [ address, 'latest', diff --git a/packages/transaction-controller/src/utils/batch.test.ts b/packages/transaction-controller/src/utils/batch.test.ts index 14834f3944a..8db0edf393a 100644 --- a/packages/transaction-controller/src/utils/batch.test.ts +++ b/packages/transaction-controller/src/utils/batch.test.ts @@ -1,4 +1,6 @@ -import { ORIGIN_METAMASK, type AddResult } from '@metamask/approval-controller'; +/* eslint-disable @typescript-eslint/unbound-method */ +import { ORIGIN_METAMASK } from '@metamask/approval-controller'; +import type { AddResult } from '@metamask/approval-controller'; import { ApprovalType } from '@metamask/controller-utils'; import { rpcErrors, errorCodes } from '@metamask/rpc-errors'; import { cloneDeep } from 'lodash'; @@ -20,17 +22,19 @@ import { } from './feature-flags'; import { simulateGasBatch } from './gas'; import { validateBatchRequest } from './validation'; -import type { TransactionControllerState } from '..'; import { TransactionEnvelopeType, - type TransactionControllerMessenger, - type TransactionMeta, determineTransactionType, TransactionType, GasFeeEstimateLevel, GasFeeEstimateType, TransactionStatus, } from '..'; +import type { + TransactionControllerMessenger, + TransactionControllerState, + TransactionMeta, +} from '..'; import { flushPromises } from '../../../../tests/helpers'; import { DefaultGasFeeFlow } from '../gas-flows/DefaultGasFeeFlow'; import { SequentialPublishBatchHook } from '../hooks/SequentialPublishBatchHook'; @@ -180,7 +184,7 @@ function mockRequestApproval( rejectPromise = reject; }); - const approveTransaction = (approvalResult?: Partial) => { + const approveTransaction = (approvalResult?: Partial): void => { resolvePromise({ resultCallbacks: { success() { @@ -198,7 +202,7 @@ function mockRequestApproval( rejectionError: unknown = { code: errorCodes.provider.userRejectedRequest, }, - ) => { + ): void => { rejectPromise(rejectionError); }; @@ -647,6 +651,44 @@ describe('Batch Utils', () => { ); }); + it('does not use type 4 if not upgraded but disableUpgrade set', async () => { + isAccountUpgradedToEIP7702Mock.mockResolvedValueOnce({ + delegationAddress: undefined, + isSupported: false, + }); + + addTransactionMock.mockResolvedValueOnce({ + transactionMeta: TRANSACTION_META_MOCK, + result: Promise.resolve(''), + }); + + generateEIP7702BatchTransactionMock.mockReturnValueOnce( + TRANSACTION_BATCH_PARAMS_MOCK, + ); + + getEIP7702UpgradeContractAddressMock.mockReturnValueOnce( + CONTRACT_ADDRESS_MOCK, + ); + + request.request.disableUpgrade = true; + + await addTransactionBatch(request); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).toHaveBeenCalledWith( + { + from: FROM_MOCK, + to: TO_MOCK, + data: DATA_MOCK, + value: VALUE_MOCK, + }, + expect.objectContaining({ + networkClientId: NETWORK_CLIENT_ID_MOCK, + requireApproval: true, + }), + ); + }); + it('passes nested transactions to add transaction', async () => { isAccountUpgradedToEIP7702Mock.mockResolvedValueOnce({ delegationAddress: undefined, @@ -709,6 +751,50 @@ describe('Batch Utils', () => { ); }); + it('includes gas fee properties from first nested transaction in batch transaction', async () => { + isAccountUpgradedToEIP7702Mock.mockResolvedValueOnce({ + delegationAddress: undefined, + isSupported: true, + }); + + addTransactionMock.mockResolvedValueOnce({ + transactionMeta: TRANSACTION_META_MOCK, + result: Promise.resolve(''), + }); + + generateEIP7702BatchTransactionMock.mockReturnValueOnce( + TRANSACTION_BATCH_PARAMS_MOCK, + ); + + request.request.transactions = [ + { + params: { + ...TRANSACTION_BATCH_PARAMS_MOCK, + maxFeePerGas: MAX_FEE_PER_GAS_MOCK, + maxPriorityFeePerGas: MAX_PRIORITY_FEE_PER_GAS_MOCK, + }, + }, + { + params: { + ...TRANSACTION_BATCH_PARAMS_MOCK, + maxFeePerGas: '0x999', + maxPriorityFeePerGas: '0x888', + }, + }, + ]; + + await addTransactionBatch(request); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).toHaveBeenCalledWith( + expect.objectContaining({ + maxFeePerGas: MAX_FEE_PER_GAS_MOCK, + maxPriorityFeePerGas: MAX_PRIORITY_FEE_PER_GAS_MOCK, + }), + expect.anything(), + ); + }); + it('throws if chain not supported', async () => { doesChainSupportEIP7702Mock.mockReturnValue(false); @@ -734,6 +820,66 @@ describe('Batch Utils', () => { ); }); + it('overwrites upgrade if account upgraded to unsupported contract and overwriteUpgrade is true', async () => { + isAccountUpgradedToEIP7702Mock.mockResolvedValueOnce({ + delegationAddress: CONTRACT_ADDRESS_MOCK, + isSupported: false, + }); + + addTransactionMock.mockResolvedValueOnce({ + transactionMeta: TRANSACTION_META_MOCK, + result: Promise.resolve(''), + }); + + generateEIP7702BatchTransactionMock.mockReturnValueOnce( + TRANSACTION_BATCH_PARAMS_MOCK, + ); + + getEIP7702UpgradeContractAddressMock.mockReturnValueOnce( + CONTRACT_ADDRESS_MOCK, + ); + + request.request.overwriteUpgrade = true; + + await addTransactionBatch(request); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationList: [{ address: CONTRACT_ADDRESS_MOCK }], + }), + expect.anything(), + ); + }); + + it('does not overwrite upgrade if account upgraded and supported', async () => { + isAccountUpgradedToEIP7702Mock.mockResolvedValueOnce({ + delegationAddress: CONTRACT_ADDRESS_MOCK, + isSupported: true, + }); + + addTransactionMock.mockResolvedValueOnce({ + transactionMeta: TRANSACTION_META_MOCK, + result: Promise.resolve(''), + }); + + generateEIP7702BatchTransactionMock.mockReturnValueOnce( + TRANSACTION_BATCH_PARAMS_MOCK, + ); + + request.request.overwriteUpgrade = true; + + await addTransactionBatch(request); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).toHaveBeenCalledWith( + expect.not.objectContaining({ + authorizationList: expect.anything(), + }), + expect.anything(), + ); + }); + it('throws if account not upgraded and no upgrade address', async () => { isAccountUpgradedToEIP7702Mock.mockResolvedValueOnce({ delegationAddress: undefined, @@ -1645,13 +1791,13 @@ describe('Batch Utils', () => { const setupSequentialPublishBatchHookMock = ( hookImplementation: () => PublishBatchHook | undefined, - ) => { + ): void => { sequentialPublishBatchHookMock.mockReturnValue({ getHook: hookImplementation, } as unknown as SequentialPublishBatchHook); }; - const executePublishHooks = async () => { + const executePublishHooks = async (): Promise => { const publishHooks = addTransactionMock.mock.calls.map( ([, options]) => options.publishHook, ); @@ -1670,7 +1816,7 @@ describe('Batch Utils', () => { await flushPromises(); }; - const mockSequentialPublishBatchHookResults = () => { + const mockSequentialPublishBatchHookResults = (): void => { sequentialPublishBatchHook.mockResolvedValueOnce({ results: [ { transactionHash: TRANSACTION_HASH_MOCK }, @@ -1679,7 +1825,7 @@ describe('Batch Utils', () => { }); }; - const assertSequentialPublishBatchHookCalled = () => { + const assertSequentialPublishBatchHookCalled = (): void => { expect(sequentialPublishBatchHookMock).toHaveBeenCalledTimes(1); expect(sequentialPublishBatchHook).toHaveBeenCalledTimes(1); expect(sequentialPublishBatchHook).toHaveBeenCalledWith( diff --git a/packages/transaction-controller/src/utils/batch.ts b/packages/transaction-controller/src/utils/batch.ts index b0b3385d3b7..fdf028c73ba 100644 --- a/packages/transaction-controller/src/utils/batch.ts +++ b/packages/transaction-controller/src/utils/batch.ts @@ -31,15 +31,19 @@ import { } from './feature-flags'; import { simulateGasBatch } from './gas'; import { validateBatchRequest } from './validation'; -import type { GetSimulationConfig, TransactionControllerState } from '..'; import { determineTransactionType, GasFeeEstimateLevel, TransactionStatus, - type BatchTransactionParams, - type TransactionController, - type TransactionControllerMessenger, - type TransactionMeta, +} from '..'; +import type { + BatchTransactionParams, + GetSimulationConfig, + PublishBatchHookRequest, + TransactionController, + TransactionControllerMessenger, + TransactionControllerState, + TransactionMeta, } from '..'; import { DefaultGasFeeFlow } from '../gas-flows/DefaultGasFeeFlow'; import { updateTransactionGasEstimates } from '../helpers/GasFeePoller'; @@ -47,6 +51,7 @@ import type { PendingTransactionTracker } from '../helpers/PendingTransactionTra import { CollectPublishHook } from '../hooks/CollectPublishHook'; import { SequentialPublishBatchHook } from '../hooks/SequentialPublishBatchHook'; import { projectLogger } from '../logger'; +import { TransactionEnvelopeType, TransactionType } from '../types'; import type { NestedTransactionMetadata, SecurityAlertResponse, @@ -60,12 +65,7 @@ import type { IsAtomicBatchSupportedResultEntry, TransactionBatchMeta, } from '../types'; -import { - TransactionEnvelopeType, - type TransactionBatchResult, - type TransactionParams, - TransactionType, -} from '../types'; +import type { TransactionBatchResult, TransactionParams } from '../types'; type UpdateStateCallback = ( callback: ( @@ -257,15 +257,21 @@ async function getNestedTransactionMeta( const { from } = request; const { params, type: requestedType } = singleRequest; + if (requestedType) { + return { + ...params, + type: requestedType, + }; + } + const { type: determinedType } = await determineTransactionType( { from, ...params }, ethQuery, ); - const type = requestedType ?? determinedType; return { ...params, - type, + type: determinedType, }; } @@ -277,7 +283,7 @@ async function getNestedTransactionMeta( */ async function addTransactionBatchWith7702( request: AddTransactionBatchRequest, -) { +): Promise { const { addTransaction, getChainId, @@ -288,12 +294,15 @@ async function addTransactionBatchWith7702( const { batchId: batchIdOverride, + disableUpgrade, from, gasFeeToken, networkClientId, origin, + overwriteUpgrade, requireApproval, securityAlertId, + skipInitialGasEstimate, transactions, validateSecurity, } = userRequest; @@ -311,19 +320,31 @@ async function addTransactionBatchWith7702( throw rpcErrors.internal(ERROR_MESSGE_PUBLIC_KEY); } - const { delegationAddress, isSupported } = await isAccountUpgradedToEIP7702( - from, - chainId, - publicKeyEIP7702, - messenger, - ethQuery, - ); + let requiresUpgrade = false; - log('Account', { delegationAddress, isSupported }); + if (!disableUpgrade) { + const { delegationAddress, isSupported } = await isAccountUpgradedToEIP7702( + from, + chainId, + publicKeyEIP7702, + messenger, + ethQuery, + ); + + log('Account', { delegationAddress, isSupported }); + + if (!isSupported && delegationAddress && !overwriteUpgrade) { + log('Account upgraded to unsupported contract', from, delegationAddress); + throw rpcErrors.internal('Account upgraded to unsupported contract'); + } + + requiresUpgrade = !isSupported; - if (!isSupported && delegationAddress) { - log('Account upgraded to unsupported contract', from, delegationAddress); - throw rpcErrors.internal('Account upgraded to unsupported contract'); + if (requiresUpgrade && delegationAddress) { + log('Overwriting authorization as already upgraded', { + current: delegationAddress, + }); + } } const nestedTransactions = await Promise.all( @@ -335,11 +356,13 @@ async function addTransactionBatchWith7702( const batchParams = generateEIP7702BatchTransaction(from, nestedTransactions); const txParams: TransactionParams = { - from, ...batchParams, + from, + maxFeePerGas: nestedTransactions[0]?.maxFeePerGas, + maxPriorityFeePerGas: nestedTransactions[0]?.maxPriorityFeePerGas, }; - if (!isSupported) { + if (requiresUpgrade) { const upgradeContractAddress = getEIP7702UpgradeContractAddress( chainId, messenger, @@ -409,6 +432,7 @@ async function addTransactionBatchWith7702( origin, requireApproval, securityAlertResponse, + skipInitialGasEstimate, type: TransactionType.batch, }); @@ -533,7 +557,11 @@ async function addTransactionBatchWithHook( signedTx: signedTransactions[i], })); - const hookParams = { from, networkClientId, transactions }; + const hookParams: PublishBatchHookRequest = { + from, + networkClientId, + transactions, + }; log('Calling publish batch hook', hookParams); @@ -594,7 +622,9 @@ async function processTransactionWithHook( request: AddTransactionBatchRequest, txBatchMeta: TransactionBatchMeta | undefined, index: number, -) { +): Promise< + Omit & { type?: TransactionType } +> { const { assetsFiatValues, existingTransaction, params, type } = nestedTransaction; @@ -734,7 +764,7 @@ async function requestApproval( 'ApprovalController:addRequest', { id, - origin: origin || ORIGIN_METAMASK, + origin: origin ?? ORIGIN_METAMASK, requestData, expectsResult: true, type, @@ -752,7 +782,7 @@ async function requestApproval( function addBatchMetadata( transactionBatchMeta: TransactionBatchMeta, update: UpdateStateCallback, -) { +): void { update((state) => { state.transactionBatches = [ ...state.transactionBatches, @@ -914,7 +944,7 @@ async function convertTransactionToEIP7702({ }; nestedTransactions: NestedTransactionMetadata[]; txParams: TransactionParams; -}) { +}): Promise { const { getTransaction, estimateGas, updateTransaction } = request; const existingTransactionMeta = getTransaction(existingTransaction.id); diff --git a/packages/transaction-controller/src/utils/eip7702.test.ts b/packages/transaction-controller/src/utils/eip7702.test.ts index 069b31f3ebc..6b769d4b159 100644 --- a/packages/transaction-controller/src/utils/eip7702.test.ts +++ b/packages/transaction-controller/src/utils/eip7702.test.ts @@ -1,11 +1,10 @@ import { query } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; -import { - Messenger, - type MockAnyNamespace, - type MessengerActions, - type MessengerEvents, - MOCK_ANY_NAMESPACE, +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, } from '@metamask/messenger'; import type { Hex } from '@metamask/utils'; import { remove0x } from '@metamask/utils'; @@ -24,8 +23,9 @@ import { } from './feature-flags'; import type { KeyringControllerSignEip7702AuthorizationAction } from '../../../keyring-controller/src'; import type { TransactionControllerMessenger } from '../TransactionController'; +import { TransactionStatus } from '../types'; import type { AuthorizationList } from '../types'; -import { TransactionStatus, type TransactionMeta } from '../types'; +import type { TransactionMeta } from '../types'; jest.mock('../utils/feature-flags'); diff --git a/packages/transaction-controller/src/utils/eip7702.ts b/packages/transaction-controller/src/utils/eip7702.ts index f6249b760bd..ce526f74292 100644 --- a/packages/transaction-controller/src/utils/eip7702.ts +++ b/packages/transaction-controller/src/utils/eip7702.ts @@ -2,7 +2,8 @@ import { defaultAbiCoder } from '@ethersproject/abi'; import { Contract } from '@ethersproject/contracts'; import { query, toHex } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; -import { createModuleLogger, type Hex, add0x } from '@metamask/utils'; +import { createModuleLogger, add0x } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { getEIP7702ContractAddresses, @@ -35,7 +36,7 @@ const log = createModuleLogger(projectLogger, 'eip-7702'); export function doesChainSupportEIP7702( chainId: Hex, messenger: TransactionControllerMessenger, -) { +): boolean { const supportedChains = getEIP7702SupportedChains(messenger); return supportedChains.some( @@ -82,7 +83,10 @@ export async function isAccountUpgradedToEIP7702( publicKey: Hex, messenger: TransactionControllerMessenger, ethQuery: EthQuery, -) { +): Promise<{ + delegationAddress: Hex | undefined; + isSupported: boolean; +}> { const contractAddresses = getEIP7702ContractAddresses( chainId, messenger, @@ -223,8 +227,11 @@ async function signAuthorization( }, ); + // eslint-disable-next-line id-length const r = signature.slice(0, 66) as Hex; + // eslint-disable-next-line id-length const s = add0x(signature.slice(66, 130)); + // eslint-disable-next-line id-length const v = parseInt(signature.slice(130, 132), 16); const yParity = toHex(v - 27 === 0 ? 0 : 1); @@ -262,9 +269,7 @@ function prepareAuthorization( const chainId = existingChainId ?? transactionChainId; let nonce = existingNonce; - if (nonce === undefined) { - nonce = toHex(parseInt(transactionNonce as string, 16) + 1 + index); - } + nonce ??= toHex(parseInt(transactionNonce as string, 16) + 1 + index); const result = { ...authorization, diff --git a/packages/transaction-controller/src/utils/external-transactions.test.ts b/packages/transaction-controller/src/utils/external-transactions.test.ts index 3d44ce1a581..d5e86b06398 100644 --- a/packages/transaction-controller/src/utils/external-transactions.test.ts +++ b/packages/transaction-controller/src/utils/external-transactions.test.ts @@ -5,7 +5,10 @@ import type { TransactionMeta } from '../types'; import { TransactionStatus } from '../types'; describe('validateConfirmedExternalTransaction', () => { - const mockTransactionMeta = (status: TransactionStatus, nonce: string) => { + const mockTransactionMeta = ( + status: TransactionStatus, + nonce: string, + ): TransactionMeta => { const meta = { status, txParams: { nonce }, diff --git a/packages/transaction-controller/src/utils/external-transactions.ts b/packages/transaction-controller/src/utils/external-transactions.ts index af316ea2134..c7174764e21 100644 --- a/packages/transaction-controller/src/utils/external-transactions.ts +++ b/packages/transaction-controller/src/utils/external-transactions.ts @@ -15,8 +15,8 @@ export function validateConfirmedExternalTransaction( transactionMeta?: TransactionMeta, confirmedTxs?: TransactionMeta[], pendingTxs?: TransactionMeta[], -) { - if (!transactionMeta || !transactionMeta.txParams) { +): void { + if (!transactionMeta?.txParams) { throw rpcErrors.invalidParams( '"transactionMeta" or "transactionMeta.txParams" is missing', ); diff --git a/packages/transaction-controller/src/utils/feature-flags.test.ts b/packages/transaction-controller/src/utils/feature-flags.test.ts index 1d1e8c91b93..fe781ad4057 100644 --- a/packages/transaction-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-controller/src/utils/feature-flags.test.ts @@ -1,9 +1,8 @@ -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MockAnyNamespace, - type MessengerActions, - type MessengerEvents, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, } from '@metamask/messenger'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { Hex } from '@metamask/utils'; @@ -68,7 +67,9 @@ describe('Feature Flags Utils', () => { * * @param featureFlags - The feature flags to mock. */ - function mockFeatureFlags(featureFlags: TransactionControllerFeatureFlags) { + function mockFeatureFlags( + featureFlags: TransactionControllerFeatureFlags, + ): void { getFeatureFlagsMock.mockReturnValue({ cacheTimestamp: 0, remoteFeatureFlags: featureFlags, @@ -350,7 +351,7 @@ describe('Feature Flags Utils', () => { mockFeatureFlags({}); const params = getAcceleratedPollingParams( - CHAIN_ID_MOCK as Hex, + CHAIN_ID_MOCK, controllerMessenger, ); @@ -375,7 +376,7 @@ describe('Feature Flags Utils', () => { }); const params = getAcceleratedPollingParams( - CHAIN_ID_MOCK as Hex, + CHAIN_ID_MOCK, controllerMessenger, ); @@ -396,7 +397,7 @@ describe('Feature Flags Utils', () => { }); const params = getAcceleratedPollingParams( - CHAIN_ID_MOCK as Hex, + CHAIN_ID_MOCK, controllerMessenger, ); @@ -423,7 +424,7 @@ describe('Feature Flags Utils', () => { }); const params = getAcceleratedPollingParams( - CHAIN_ID_MOCK as Hex, + CHAIN_ID_MOCK, controllerMessenger, ); @@ -450,7 +451,7 @@ describe('Feature Flags Utils', () => { }); const params = getAcceleratedPollingParams( - CHAIN_ID_MOCK as Hex, + CHAIN_ID_MOCK, controllerMessenger, ); @@ -477,7 +478,7 @@ describe('Feature Flags Utils', () => { }); const params = getAcceleratedPollingParams( - CHAIN_ID_MOCK as Hex, + CHAIN_ID_MOCK, controllerMessenger, ); diff --git a/packages/transaction-controller/src/utils/feature-flags.ts b/packages/transaction-controller/src/utils/feature-flags.ts index f529441c0b4..d371d3f6c30 100644 --- a/packages/transaction-controller/src/utils/feature-flags.ts +++ b/packages/transaction-controller/src/utils/feature-flags.ts @@ -1,4 +1,5 @@ -import { createModuleLogger, type Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { isValidSignature } from './signature'; import { padHexToEvenLength } from './utils'; @@ -253,13 +254,13 @@ export function getAcceleratedPollingParams( featureFlags?.[FeatureFlag.Transactions]?.acceleratedPolling; const countMax = - acceleratedPollingParams?.perChainConfig?.[chainId]?.countMax || - acceleratedPollingParams?.defaultCountMax || + acceleratedPollingParams?.perChainConfig?.[chainId]?.countMax ?? + acceleratedPollingParams?.defaultCountMax ?? DEFAULT_ACCELERATED_POLLING_COUNT_MAX; const intervalMs = - acceleratedPollingParams?.perChainConfig?.[chainId]?.intervalMs || - acceleratedPollingParams?.defaultIntervalMs || + acceleratedPollingParams?.perChainConfig?.[chainId]?.intervalMs ?? + acceleratedPollingParams?.defaultIntervalMs ?? DEFAULT_ACCELERATED_POLLING_INTERVAL_MS; return { countMax, intervalMs }; @@ -280,10 +281,10 @@ export function getGasFeeRandomisation( const featureFlags = getFeatureFlags(messenger); const gasFeeRandomisation = - featureFlags?.[FeatureFlag.Transactions]?.gasFeeRandomisation || {}; + featureFlags?.[FeatureFlag.Transactions]?.gasFeeRandomisation ?? {}; return { - randomisedGasFeeDigits: gasFeeRandomisation.randomisedGasFeeDigits || {}, + randomisedGasFeeDigits: gasFeeRandomisation.randomisedGasFeeDigits ?? {}, preservedNumberOfDigits: gasFeeRandomisation.preservedNumberOfDigits, }; } diff --git a/packages/transaction-controller/src/utils/first-time-interaction.test.ts b/packages/transaction-controller/src/utils/first-time-interaction.test.ts index ed696e3bad8..4ea633e7f96 100644 --- a/packages/transaction-controller/src/utils/first-time-interaction.test.ts +++ b/packages/transaction-controller/src/utils/first-time-interaction.test.ts @@ -106,6 +106,7 @@ describe('updateFirstTimeInteraction', () => { const transactionMetaWithData = { ...mockTransactionMeta, txParams: { ...mockTransactionMeta.txParams, data: '0xabcdef' }, + type: TransactionType.tokenMethodTransfer, }; mockDecodeTransactionData.mockReturnValue({ @@ -136,6 +137,7 @@ describe('updateFirstTimeInteraction', () => { const transactionMetaWithData = { ...mockTransactionMeta, txParams: { ...mockTransactionMeta.txParams, data: '0xabcdef' }, + type: TransactionType.tokenMethodTransfer, }; mockDecodeTransactionData.mockReturnValue({ @@ -165,6 +167,7 @@ describe('updateFirstTimeInteraction', () => { const transactionMetaWithData = { ...mockTransactionMeta, txParams: { ...mockTransactionMeta.txParams, data: '0xabcdef' }, + type: TransactionType.tokenMethodTransferFrom, }; mockDecodeTransactionData.mockReturnValue({ diff --git a/packages/transaction-controller/src/utils/first-time-interaction.ts b/packages/transaction-controller/src/utils/first-time-interaction.ts index 6b6d7d22b6d..32fa4fe90ec 100644 --- a/packages/transaction-controller/src/utils/first-time-interaction.ts +++ b/packages/transaction-controller/src/utils/first-time-interaction.ts @@ -4,11 +4,10 @@ import { hexToNumber } from '@metamask/utils'; import { decodeTransactionData } from './transaction-type'; import { validateParamTo } from './validation'; -import { - getAccountAddressRelationship, - type GetAccountAddressRelationshipRequest, -} from '../api/accounts-api'; +import { getAccountAddressRelationship } from '../api/accounts-api'; +import type { GetAccountAddressRelationshipRequest } from '../api/accounts-api'; import { projectLogger as log } from '../logger'; +import { TransactionType } from '../types'; import type { TransactionMeta } from '../types'; type UpdateFirstTimeInteractionRequest = { @@ -57,20 +56,25 @@ export async function updateFirstTimeInteraction({ chainId, id: transactionId, txParams: { data, from, to }, + type, } = transactionMeta; let recipient; - if (data) { + if ( + data && + [ + TransactionType.tokenMethodTransfer, + TransactionType.tokenMethodTransferFrom, + ].includes(type as TransactionType) + ) { const parsedData = decodeTransactionData(data) as TransactionDescription; // _to is for ERC20, ERC721 and USDC // to is for ERC1155 - recipient = parsedData?.args?._to || parsedData?.args?.to; + recipient = parsedData?.args?._to ?? parsedData?.args?.to; } - if (!recipient) { - // Use as fallback if no recipient is found from decode or no data is present - recipient = to; - } + // Use as fallback if no recipient is found from decode or no data is present + recipient ??= to; const request: GetAccountAddressRelationshipRequest = { chainId: hexToNumber(chainId), diff --git a/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts b/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts index cd89a1eafc5..de2908ddfd5 100644 --- a/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts +++ b/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts @@ -431,6 +431,11 @@ describe('Gas Fee Tokens Utils', () => { await checkGasFeeTokenBeforePublish(request); expect(request.fetchGasFeeTokens).toHaveBeenCalledTimes(1); + expect(request.fetchGasFeeTokens).toHaveBeenCalledWith( + expect.objectContaining({ + isExternalSign: true, + }), + ); }); it('sets external sign to true if gas fee token found', async () => { diff --git a/packages/transaction-controller/src/utils/gas-fee-tokens.ts b/packages/transaction-controller/src/utils/gas-fee-tokens.ts index 1f7d6e94852..c89fbf88af2 100644 --- a/packages/transaction-controller/src/utils/gas-fee-tokens.ts +++ b/packages/transaction-controller/src/utils/gas-fee-tokens.ts @@ -12,11 +12,11 @@ import type { TransactionControllerMessenger, TransactionMeta, } from '..'; +import { simulateTransactions } from '../api/simulation-api'; import type { SimulationRequestTransaction } from '../api/simulation-api'; -import { - simulateTransactions, - type SimulationResponse, - type SimulationResponseTransaction, +import type { + SimulationResponse, + SimulationResponseTransaction, } from '../api/simulation-api'; import { projectLogger } from '../logger'; import type { GetSimulationConfig } from '../types'; @@ -53,7 +53,10 @@ export async function getGasFeeTokens({ publicKeyEIP7702, transactionMeta, getSimulationConfig, -}: GetGasFeeTokensRequest) { +}: GetGasFeeTokensRequest): Promise<{ + gasFeeTokens: GasFeeToken[]; + isGasFeeSponsored: boolean; +}> { const { delegationAddress, txParams } = transactionMeta; const { authorizationList: authorizationListRequest } = txParams; const data = txParams.data as Hex; @@ -73,13 +76,13 @@ export async function getGasFeeTokens({ | SimulationRequestTransaction['authorizationList'] | undefined = authorizationListRequest?.map((authorization) => ({ address: authorization.address, - from: from as Hex, + from, })); if (with7702 && !delegationAddress && !authorizationList) { authorizationList = buildAuthorizationList({ chainId, - from: from as Hex, + from, messenger, publicKeyEIP7702, }); @@ -139,7 +142,7 @@ export async function checkGasFeeTokenBeforePublish({ transactionId: string, fn: (tx: TransactionMeta) => void, ) => void; -}) { +}): Promise { const { isGasFeeTokenIgnoredIfBalance, selectedGasFeeToken } = transaction; if (!selectedGasFeeToken || !isGasFeeTokenIgnoredIfBalance) { @@ -166,7 +169,10 @@ export async function checkGasFeeTokenBeforePublish({ return; } - const gasFeeTokens = await fetchGasFeeTokens(transaction); + const gasFeeTokens = await fetchGasFeeTokens({ + ...transaction, + isExternalSign: true, + }); updateTransaction(transaction.id, (tx) => { tx.gasFeeTokens = gasFeeTokens; @@ -178,7 +184,8 @@ export async function checkGasFeeTokenBeforePublish({ if ( !gasFeeTokens?.some( - (t) => t.tokenAddress.toLowerCase() === selectedGasFeeToken.toLowerCase(), + (token) => + token.tokenAddress.toLowerCase() === selectedGasFeeToken.toLowerCase(), ) ) { throw new Error('Gas fee token not found and insufficient native balance'); @@ -261,7 +268,7 @@ function buildAuthorizationList({ return [ { address: upgradeAddress, - from: from as Hex, + from, }, ]; } diff --git a/packages/transaction-controller/src/utils/gas-fees.test.ts b/packages/transaction-controller/src/utils/gas-fees.test.ts index 801f629526d..1adecd8d7fc 100644 --- a/packages/transaction-controller/src/utils/gas-fees.test.ts +++ b/packages/transaction-controller/src/utils/gas-fees.test.ts @@ -58,7 +58,7 @@ const FLOW_RESPONSE_GAS_PRICE_MOCK = { * @param value - The number to convert. * @returns The hex string. */ -function toHex(value: number) { +function toHex(value: number): string { return `0x${value.toString(16)}`; } @@ -84,7 +84,7 @@ describe('gas-fees', () => { * * @param response - The response to return. */ - function mockGasFeeFlowMockResponse(response: GasFeeFlowResponse) { + function mockGasFeeFlowMockResponse(response: GasFeeFlowResponse): void { gasFeeFlowMock.getGasFees.mockResolvedValue(response); } diff --git a/packages/transaction-controller/src/utils/gas-fees.ts b/packages/transaction-controller/src/utils/gas-fees.ts index b395abbf72a..e57aa71d481 100644 --- a/packages/transaction-controller/src/utils/gas-fees.ts +++ b/packages/transaction-controller/src/utils/gas-fees.ts @@ -56,7 +56,9 @@ const log = createModuleLogger(projectLogger, 'gas-fees'); * * @param request - The request object. */ -export async function updateGasFees(request: UpdateGasFeesRequest) { +export async function updateGasFees( + request: UpdateGasFeesRequest, +): Promise { const { txMeta } = request; const initialParams = { ...txMeta.txParams }; @@ -110,7 +112,7 @@ export async function updateGasFees(request: UpdateGasFeesRequest) { * @param value - The GWEI value as a decimal string. * @returns The WEI value in hex. */ -export function gweiDecimalToWeiHex(value: string) { +export function gweiDecimalToWeiHex(value: string): Hex { return toHex(gweiDecToWEIBN(value)); } @@ -145,7 +147,7 @@ function getMaxFeePerGas(request: GetGasFeeRequest): string | undefined { } if (savedGasFees) { - const maxFeePerGas = gweiDecimalToWeiHex(savedGasFees.maxBaseFee as string); + const maxFeePerGas = gweiDecimalToWeiHex(savedGasFees.maxBaseFee); log('Using maxFeePerGas from savedGasFees', maxFeePerGas); return maxFeePerGas; } @@ -325,11 +327,8 @@ function getUserFeeLevel(request: GetGasFeeRequest): UserFeeLevel | undefined { * * @param txMeta - The transaction metadata. */ -function updateDefaultGasEstimates(txMeta: TransactionMeta) { - if (!txMeta.defaultGasEstimates) { - txMeta.defaultGasEstimates = {}; - } - +function updateDefaultGasEstimates(txMeta: TransactionMeta): void { + txMeta.defaultGasEstimates ??= {}; txMeta.defaultGasEstimates.maxFeePerGas = txMeta.txParams.maxFeePerGas; txMeta.defaultGasEstimates.maxPriorityFeePerGas = diff --git a/packages/transaction-controller/src/utils/gas-flow.ts b/packages/transaction-controller/src/utils/gas-flow.ts index a641c74dc12..94e21e5e579 100644 --- a/packages/transaction-controller/src/utils/gas-flow.ts +++ b/packages/transaction-controller/src/utils/gas-flow.ts @@ -5,20 +5,19 @@ import type { GasFeeEstimates, LegacyGasPriceEstimate, } from '@metamask/gas-fee-controller'; -import { type GasFeeState } from '@metamask/gas-fee-controller'; +import type { GasFeeState } from '@metamask/gas-fee-controller'; import type { TransactionControllerMessenger } from '../TransactionController'; +import { GasFeeEstimateLevel, GasFeeEstimateType } from '../types'; import type { FeeMarketGasFeeEstimates, GasPriceGasFeeEstimates, LegacyGasFeeEstimates, } from '../types'; -import { - type GasFeeFlow, - type TransactionMeta, - type FeeMarketGasFeeEstimateForLevel, - GasFeeEstimateLevel, - GasFeeEstimateType, +import type { + GasFeeFlow, + TransactionMeta, + FeeMarketGasFeeEstimateForLevel, } from '../types'; type MergeGasFeeEstimatesRequest = { diff --git a/packages/transaction-controller/src/utils/gas.test.ts b/packages/transaction-controller/src/utils/gas.test.ts index 32c12598a2d..2a37eb04f58 100644 --- a/packages/transaction-controller/src/utils/gas.test.ts +++ b/packages/transaction-controller/src/utils/gas.test.ts @@ -1,6 +1,7 @@ import { query } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; -import { remove0x, type Hex } from '@metamask/utils'; +import { remove0x } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { cloneDeep } from 'lodash'; import { DELEGATION_PREFIX } from './eip7702'; @@ -23,7 +24,8 @@ import type { } from '../api/simulation-api'; import { simulateTransactions } from '../api/simulation-api'; import type { TransactionControllerMessenger } from '../TransactionController'; -import { TransactionEnvelopeType, type TransactionMeta } from '../types'; +import { TransactionEnvelopeType } from '../types'; +import type { TransactionMeta } from '../types'; import type { AuthorizationList, TransactionBatchSingleRequest, @@ -97,7 +99,7 @@ const UPDATE_GAS_REQUEST_MOCK = { * @param value - The number to convert. * @returns The hex string. */ -function toHex(value: number) { +function toHex(value: number): Hex { return `0x${value.toString(16)}`; } @@ -140,7 +142,7 @@ describe('gas', () => { estimateGasOverridesResponse?: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any estimateGasOverridesError?: any; - }) { + }): void { if (getCodeResponse !== undefined) { queryMock.mockResolvedValueOnce(getCodeResponse); } @@ -165,7 +167,7 @@ describe('gas', () => { /** * Assert that estimateGas was not called. */ - function expectEstimateGasNotCalled() { + function expectEstimateGasNotCalled(): void { expect(queryMock).not.toHaveBeenCalledWith( expect.anything(), 'estimateGas', @@ -643,7 +645,7 @@ describe('gas', () => { simulateTransactionsMock.mockResolvedValueOnce({ transactions: [ { - gasLimit: toHex(SIMULATE_GAS_MOCK) as Hex, + gasLimit: toHex(SIMULATE_GAS_MOCK), }, ], } as SimulationResponse); @@ -698,7 +700,7 @@ describe('gas', () => { simulateTransactionsMock.mockResolvedValueOnce({ transactions: [ { - gasLimit: toHex(SIMULATE_GAS_MOCK) as Hex, + gasLimit: toHex(SIMULATE_GAS_MOCK), }, ], } as SimulationResponse); @@ -734,7 +736,7 @@ describe('gas', () => { simulateTransactionsMock.mockResolvedValueOnce({ transactions: [ { - gasUsed: toHex(SIMULATE_GAS_MOCK) as Hex, + gasUsed: toHex(SIMULATE_GAS_MOCK), }, ], } as SimulationResponse); @@ -791,7 +793,7 @@ describe('gas', () => { simulateTransactionsMock.mockResolvedValueOnce({ transactions: [ { - gasUsed: toHex(SIMULATE_GAS_MOCK) as Hex, + gasUsed: toHex(SIMULATE_GAS_MOCK), }, ], } as SimulationResponse); diff --git a/packages/transaction-controller/src/utils/gas.ts b/packages/transaction-controller/src/utils/gas.ts index d8ee5bc0ec7..2f4ce614d93 100644 --- a/packages/transaction-controller/src/utils/gas.ts +++ b/packages/transaction-controller/src/utils/gas.ts @@ -15,14 +15,13 @@ import { getGasEstimateBuffer, getGasEstimateFallback } from './feature-flags'; import { simulateTransactions } from '../api/simulation-api'; import { projectLogger } from '../logger'; import type { TransactionControllerMessenger } from '../TransactionController'; +import { TransactionEnvelopeType } from '../types'; import type { + AuthorizationList, GetSimulationConfig, TransactionBatchSingleRequest, -} from '../types'; -import { - TransactionEnvelopeType, - type TransactionMeta, - type TransactionParams, + TransactionMeta, + TransactionParams, } from '../types'; export type UpdateGasRequest = { @@ -50,7 +49,7 @@ export const DUMMY_AUTHORIZATION_SIGNATURE = * * @param request - The request object including the necessary parameters. */ -export async function updateGas(request: UpdateGasRequest) { +export async function updateGas(request: UpdateGasRequest): Promise { const { txMeta } = request; const initialParams = { ...txMeta.txParams }; @@ -64,10 +63,7 @@ export async function updateGas(request: UpdateGasRequest) { txMeta.originalGasEstimate = txMeta.txParams.gas; } - if (!txMeta.defaultGasEstimates) { - txMeta.defaultGasEstimates = {}; - } - + txMeta.defaultGasEstimates ??= {}; txMeta.defaultGasEstimates.gas = txMeta.txParams.gas; } @@ -101,7 +97,12 @@ export async function estimateGas({ getSimulationConfig: GetSimulationConfig; messenger: TransactionControllerMessenger; txParams: TransactionParams; -}) { +}): Promise<{ + blockGasLimit: string; + estimatedGas: string; + isUpgradeWithDataToSelf: boolean; + simulationFails: TransactionMeta['simulationFails']; +}> { const request = { ...txParams }; const { authorizationList, data, from, value, to } = request; @@ -124,7 +125,7 @@ export async function estimateGas({ log('Estimation fallback values', fallback); request.data = data ? add0x(data) : data; - request.value = value || '0x0'; + request.value = value ?? '0x0'; request.authorizationList = normalizeAuthorizationList( request.authorizationList, @@ -197,7 +198,7 @@ export function addGasBuffer( estimatedGas: string, blockGasLimit: string, multiplier: number, -) { +): string { const estimatedGasBN = hexToBN(estimatedGas); const maxGasBN = fractionBN( @@ -434,7 +435,7 @@ async function estimateGasUpgradeWithDataToSelf( ethQuery: EthQuery, chainId: Hex, getSimulationConfig: GetSimulationConfig, -) { +): Promise { const upgradeGas = await query(ethQuery, 'estimateGas', [ { ...txParams, @@ -450,7 +451,7 @@ async function estimateGasUpgradeWithDataToSelf( try { executeGas = await simulateGas({ - chainId: chainId as Hex, + chainId, delegationAddress, getSimulationConfig, transaction: txParams, @@ -477,9 +478,7 @@ async function estimateGasUpgradeWithDataToSelf( log('Execute gas', executeGas); const total = BNToHex( - hexToBN(upgradeGas) - .add(hexToBN(executeGas as Hex)) - .subn(INTRINSIC_GAS), + hexToBN(upgradeGas).add(hexToBN(executeGas)).subn(INTRINSIC_GAS), ); log('Total type 4 gas', total); @@ -519,7 +518,7 @@ async function simulateGas({ }, ], overrides: { - [transaction.from as string]: { + [transaction.from]: { code: delegationAddress && ((DELEGATION_PREFIX + remove0x(delegationAddress)) as Hex), @@ -544,9 +543,9 @@ async function simulateGas({ * @returns The authorization list with dummy values. */ function normalizeAuthorizationList( - authorizationList: TransactionParams['authorizationList'], + authorizationList: AuthorizationList | undefined, chainId: Hex, -) { +): AuthorizationList | undefined { return authorizationList?.map((authorization) => ({ ...authorization, chainId: authorization.chainId ?? chainId, @@ -569,7 +568,7 @@ function estimateGasNode( ethQuery: EthQuery, txParams: TransactionParams, delegationAddress?: Hex, -) { +): Promise { const { from } = txParams; const params = [txParams] as Json[]; @@ -577,7 +576,7 @@ function estimateGasNode( params.push('latest'); params.push({ - [from as string]: { + [from]: { code: DELEGATION_PREFIX + remove0x(delegationAddress), }, }); diff --git a/packages/transaction-controller/src/utils/history.test.ts b/packages/transaction-controller/src/utils/history.test.ts index 96bee3f6315..d4828639b67 100644 --- a/packages/transaction-controller/src/utils/history.test.ts +++ b/packages/transaction-controller/src/utils/history.test.ts @@ -1,16 +1,16 @@ import { toHex } from '@metamask/controller-utils'; -import { add0x } from '@metamask/utils'; +import { add0x, Hex } from '@metamask/utils'; import { cloneDeep } from 'lodash'; import { MAX_TRANSACTION_HISTORY_LENGTH, updateTransactionHistory, } from './history'; -import { - type TransactionHistory, - TransactionStatus, - type TransactionMeta, - type TransactionHistoryEntry, +import { TransactionStatus } from '../types'; +import type { + TransactionHistory, + TransactionMeta, + TransactionHistoryEntry, } from '../types'; describe('History', () => { @@ -396,6 +396,6 @@ function generateMockHistory({ * @param number - The address as a decimal number. * @returns The mock address */ -function generateAddress(number: number) { +function generateAddress(number: number): Hex { return add0x(number.toString(16).padStart(40, '0')); } diff --git a/packages/transaction-controller/src/utils/layer1-gas-fee-flow.test.ts b/packages/transaction-controller/src/utils/layer1-gas-fee-flow.test.ts index 4006c5ddaf0..f95ba2be458 100644 --- a/packages/transaction-controller/src/utils/layer1-gas-fee-flow.test.ts +++ b/packages/transaction-controller/src/utils/layer1-gas-fee-flow.test.ts @@ -3,11 +3,8 @@ import type { Hex } from '@metamask/utils'; import { updateTransactionLayer1GasFee } from './layer1-gas-fee-flow'; import type { TransactionControllerMessenger } from '../TransactionController'; -import { - TransactionStatus, - type Layer1GasFeeFlow, - type TransactionMeta, -} from '../types'; +import { TransactionStatus } from '../types'; +import type { Layer1GasFeeFlow, TransactionMeta } from '../types'; jest.mock('@metamask/controller-utils', () => ({ ...jest.requireActual('@metamask/controller-utils'), diff --git a/packages/transaction-controller/src/utils/layer1-gas-fee-flow.ts b/packages/transaction-controller/src/utils/layer1-gas-fee-flow.ts index 8919b1e7237..2f32e85ab9e 100644 --- a/packages/transaction-controller/src/utils/layer1-gas-fee-flow.ts +++ b/packages/transaction-controller/src/utils/layer1-gas-fee-flow.ts @@ -1,5 +1,6 @@ import type { Provider } from '@metamask/network-controller'; -import { createModuleLogger, type Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { projectLogger } from '../logger'; import type { TransactionControllerMessenger } from '../TransactionController'; @@ -24,7 +25,7 @@ export type UpdateLayer1GasFeeRequest = { */ export async function updateTransactionLayer1GasFee( request: UpdateLayer1GasFeeRequest, -) { +): Promise { const layer1GasFee = await getTransactionLayer1GasFee(request); if (!layer1GasFee) { diff --git a/packages/transaction-controller/src/utils/nonce.ts b/packages/transaction-controller/src/utils/nonce.ts index 318b6975141..21eac6df987 100644 --- a/packages/transaction-controller/src/utils/nonce.ts +++ b/packages/transaction-controller/src/utils/nonce.ts @@ -27,6 +27,7 @@ export async function getNextNonce( } = txMeta; if (isExternalSign) { + log('Skipping nonce as signed externally'); return [undefined, undefined]; } diff --git a/packages/transaction-controller/src/utils/prepare.test.ts b/packages/transaction-controller/src/utils/prepare.test.ts index c9ef425d43f..fe38c6c04c1 100644 --- a/packages/transaction-controller/src/utils/prepare.test.ts +++ b/packages/transaction-controller/src/utils/prepare.test.ts @@ -5,8 +5,8 @@ import { } from '@ethereumjs/tx'; import { prepareTransaction, serializeTransaction } from './prepare'; -import type { Authorization } from '../types'; -import { TransactionEnvelopeType, type TransactionParams } from '../types'; +import { TransactionEnvelopeType } from '../types'; +import type { Authorization, TransactionParams } from '../types'; const CHAIN_ID_MOCK = '0x123'; diff --git a/packages/transaction-controller/src/utils/prepare.ts b/packages/transaction-controller/src/utils/prepare.ts index 95ae3fb2478..3c44534d4f6 100644 --- a/packages/transaction-controller/src/utils/prepare.ts +++ b/packages/transaction-controller/src/utils/prepare.ts @@ -38,7 +38,7 @@ export function prepareTransaction( * @param transaction - The transaction object. * @returns The prefixed hex string. */ -export function serializeTransaction(transaction: TypedTransaction) { +export function serializeTransaction(transaction: TypedTransaction): Hex { return bytesToHex(transaction.serialize()); } @@ -76,7 +76,9 @@ function normalizeParams(params: TransactionParams): TransactionParams { * * @param authorizationList - The list of authorizations to normalize. */ -function normalizeAuthorizationList(authorizationList?: AuthorizationList) { +function normalizeAuthorizationList( + authorizationList?: AuthorizationList, +): void { if (!authorizationList) { return; } diff --git a/packages/transaction-controller/src/utils/retry.ts b/packages/transaction-controller/src/utils/retry.ts index e19245ef2f6..9ab1e28aed3 100644 --- a/packages/transaction-controller/src/utils/retry.ts +++ b/packages/transaction-controller/src/utils/retry.ts @@ -2,8 +2,11 @@ import { convertHexToDecimal } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; import { add0x } from '@metamask/utils'; -import type { FeeMarketEIP1559Values, GasPriceValue } from '../types'; -import { type TransactionParams } from '../types'; +import type { + FeeMarketEIP1559Values, + GasPriceValue, + TransactionParams, +} from '../types'; /** * Returns new transaction parameters with increased gas fees. diff --git a/packages/transaction-controller/src/utils/swaps.test.ts b/packages/transaction-controller/src/utils/swaps.test.ts index 147d9e9174a..4343588f779 100644 --- a/packages/transaction-controller/src/utils/swaps.test.ts +++ b/packages/transaction-controller/src/utils/swaps.test.ts @@ -1,10 +1,10 @@ +/* eslint-disable @typescript-eslint/unbound-method */ import { query } from '@metamask/controller-utils'; -import { - MOCK_ANY_NAMESPACE, - Messenger, - type MockAnyNamespace, - type MessengerActions, - type MessengerEvents, +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, } from '@metamask/messenger'; import { diff --git a/packages/transaction-controller/src/utils/swaps.ts b/packages/transaction-controller/src/utils/swaps.ts index 225073b3882..d7aa0542bd4 100644 --- a/packages/transaction-controller/src/utils/swaps.ts +++ b/packages/transaction-controller/src/utils/swaps.ts @@ -455,7 +455,7 @@ function updateSwapApprovalTransaction( * @param chainId - The hex encoded chain ID of the default swaps token to check * @returns Whether the address is the provided chain's default token address */ -function isSwapsDefaultTokenAddress(address: string, chainId: string) { +function isSwapsDefaultTokenAddress(address: string, chainId: string): boolean { if (!address || !chainId) { return false; } @@ -474,6 +474,6 @@ function isSwapsDefaultTokenAddress(address: string, chainId: string) { * @param ms - Number of milliseconds to sleep * @returns Promise that resolves after the provided number of milliseconds */ -function sleep(ms: number) { +function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/packages/transaction-controller/src/utils/transaction-type.test.ts b/packages/transaction-controller/src/utils/transaction-type.test.ts index 01e1376e4b6..75a07e9c71e 100644 --- a/packages/transaction-controller/src/utils/transaction-type.test.ts +++ b/packages/transaction-controller/src/utils/transaction-type.test.ts @@ -1,4 +1,5 @@ -import { Interface, type TransactionDescription } from '@ethersproject/abi'; +import { Interface } from '@ethersproject/abi'; +import type { TransactionDescription } from '@ethersproject/abi'; import EthQuery from '@metamask/eth-query'; import { abiERC721, @@ -15,7 +16,7 @@ import { import { FakeProvider } from '../../../../tests/fake-provider'; import { TransactionType } from '../types'; -type GetCodeCallback = (err: Error | null, result?: string) => void; +type GetCodeCallback = (error: Error | null, result?: string) => void; const ERC20Interface = new Interface(abiERC20); const ERC721Interface = new Interface(abiERC721); @@ -34,11 +35,11 @@ function createMockEthQuery( shouldThrow = false, ): EthQuery { return new (class extends EthQuery { - getCode(_to: string, cb: GetCodeCallback): void { + getCode(_to: string, callback: GetCodeCallback): void { if (shouldThrow) { - return cb(new Error('Some error')); + return callback(new Error('Some error')); } - return cb(null, getCodeResponse ?? undefined); + return callback(null, getCodeResponse ?? undefined); } })(new FakeProvider()); } diff --git a/packages/transaction-controller/src/utils/transaction-type.ts b/packages/transaction-controller/src/utils/transaction-type.ts index 3bf3f35a8e0..6144921381c 100644 --- a/packages/transaction-controller/src/utils/transaction-type.ts +++ b/packages/transaction-controller/src/utils/transaction-type.ts @@ -1,4 +1,5 @@ -import { Interface, type TransactionDescription } from '@ethersproject/abi'; +import { Interface } from '@ethersproject/abi'; +import type { TransactionDescription } from '@ethersproject/abi'; import { query } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; import { @@ -76,9 +77,7 @@ export async function determineTransactionType( TransactionType.tokenMethodTransferFrom, TransactionType.tokenMethodSafeTransferFrom, TransactionType.tokenMethodIncreaseAllowance, - ].find( - (methodName) => methodName.toLowerCase() === (name as string).toLowerCase(), - ); + ].find((methodName) => methodName.toLowerCase() === name.toLowerCase()); if (tokenMethodName) { return { type: tokenMethodName, getCodeResponse }; diff --git a/packages/transaction-controller/src/utils/utils.test.ts b/packages/transaction-controller/src/utils/utils.test.ts index fbdd94576db..a947f350664 100644 --- a/packages/transaction-controller/src/utils/utils.test.ts +++ b/packages/transaction-controller/src/utils/utils.test.ts @@ -1,3 +1,4 @@ +import { Hex } from '@metamask/utils'; import BN from 'bn.js'; import * as util from './utils'; @@ -314,7 +315,7 @@ describe('utils', () => { }); it('converts ethers-like BigNumber with toHexString', () => { - const bigNumberLike = { toHexString: () => '0x2a' }; + const bigNumberLike = { toHexString: (): Hex => '0x2a' }; const result = util.toBN(bigNumberLike); expect(result.eq(new BN(42))).toBe(true); }); diff --git a/packages/transaction-controller/src/utils/utils.ts b/packages/transaction-controller/src/utils/utils.ts index 672d4b21bd7..ac9cbff6c34 100644 --- a/packages/transaction-controller/src/utils/utils.ts +++ b/packages/transaction-controller/src/utils/utils.ts @@ -46,7 +46,9 @@ const NORMALIZERS: { [param in keyof TransactionParams]: any } = { * @param txParams - The transaction params to normalize. * @returns Normalized transaction params. */ -export function normalizeTransactionParams(txParams: TransactionParams) { +export function normalizeTransactionParams( + txParams: TransactionParams, +): TransactionParams { const normalizedTxParams: TransactionParams = { from: '' }; for (const key of getKnownPropertyNames(NORMALIZERS)) { @@ -55,9 +57,7 @@ export function normalizeTransactionParams(txParams: TransactionParams) { } } - if (!normalizedTxParams.value) { - normalizedTxParams.value = '0x0'; - } + normalizedTxParams.value ??= '0x0'; if (normalizedTxParams.gasLimit && !normalizedTxParams.gas) { normalizedTxParams.gas = normalizedTxParams.gasLimit; @@ -74,7 +74,7 @@ export function normalizeTransactionParams(txParams: TransactionParams) { * @returns Boolean that is true if the transaction is EIP-1559 (has maxFeePerGas and maxPriorityFeePerGas), otherwise returns false. */ export function isEIP1559Transaction(txParams: TransactionParams): boolean { - const hasOwnProp = (obj: TransactionParams, key: string) => + const hasOwnProp = (obj: TransactionParams, key: string): boolean => Object.prototype.hasOwnProperty.call(obj, key); return ( hasOwnProp(txParams, 'maxFeePerGas') && @@ -84,7 +84,7 @@ export function isEIP1559Transaction(txParams: TransactionParams): boolean { export const validateGasValues = ( gasValues: GasPriceValue | FeeMarketEIP1559Values, -) => { +): void => { Object.keys(gasValues).forEach((key) => { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -107,7 +107,7 @@ export const validateGasValues = ( export function validateIfTransactionUnapproved( transactionMeta: TransactionMeta | undefined, fnName: string, -) { +): void { if (transactionMeta?.status !== TransactionStatus.unapproved) { throw new Error( `TransactionsController: Can only call ${fnName} on an unapproved transaction.\n Current tx status: ${transactionMeta?.status}`, @@ -144,7 +144,7 @@ export function normalizeGasFeeValues( ): GasPriceValue | FeeMarketEIP1559Values { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - const normalize = (value: any) => + const normalize = (value: any): string => typeof value === 'string' ? add0x(value) : value; if ('gasPrice' in gasFeeValues) { @@ -178,12 +178,14 @@ function isJsonCompatible(value: unknown): value is Json { * Ensure a hex string is of even length by adding a leading 0 if necessary. * Any existing `0x` prefix is preserved but is not added if missing. * - * @param hex - The hex string to ensure is even. + * @param hexValue - The hex string to ensure is even. * @returns The hex string with an even length. */ -export function padHexToEvenLength(hex: string) { - const prefix = hex.toLowerCase().startsWith('0x') ? hex.slice(0, 2) : ''; - const data = prefix ? hex.slice(2) : hex; +export function padHexToEvenLength(hexValue: string): string { + const prefix = hexValue.toLowerCase().startsWith('0x') + ? hexValue.slice(0, 2) + : ''; + const data = prefix ? hexValue.slice(2) : hexValue; const evenData = data.length % 2 === 0 ? data : `0${data}`; return prefix + evenData; @@ -192,11 +194,11 @@ export function padHexToEvenLength(hex: string) { /** * Create a BN from a hex string, accepting an optional 0x prefix. * - * @param hex - Hex string with or without 0x prefix. + * @param hexValue - Hex string with or without 0x prefix. * @returns BN parsed as base-16. */ -export function bnFromHex(hex: string | Hex): BN { - const str = typeof hex === 'string' ? hex : (hex as string); +export function bnFromHex(hexValue: string | Hex): BN { + const str = typeof hexValue === 'string' ? hexValue : (hexValue as string); const withoutPrefix = str.startsWith('0x') || str.startsWith('0X') ? str.slice(2) : str; if (withoutPrefix.length === 0) { @@ -214,7 +216,7 @@ export function bnFromHex(hex: string | Hex): BN { */ export function toBN(value: unknown): BN { if (value instanceof BN) { - return value as BN; + return value; } if ( typeof (BN as unknown as { isBN?: (v: unknown) => boolean }).isBN === @@ -285,7 +287,7 @@ export function getPercentageChange(originalValue: BN, newValue: BN): number { export function setEnvelopeType( txParams: TransactionParams, isEIP1559Compatible: boolean, -) { +): void { if (txParams.accessList) { txParams.type = TransactionEnvelopeType.accessList; } else if (txParams.authorizationList) { diff --git a/packages/transaction-controller/src/utils/validation.ts b/packages/transaction-controller/src/utils/validation.ts index 11a6eaeca56..203aaf075a0 100644 --- a/packages/transaction-controller/src/utils/validation.ts +++ b/packages/transaction-controller/src/utils/validation.ts @@ -6,11 +6,11 @@ import type { Hex } from '@metamask/utils'; import { isStrictHexString, remove0x } from '@metamask/utils'; import { isEIP1559Transaction } from './utils'; -import type { Authorization, TransactionBatchRequest } from '../types'; -import { - TransactionEnvelopeType, - TransactionType, - type TransactionParams, +import { TransactionEnvelopeType, TransactionType } from '../types'; +import type { + Authorization, + TransactionBatchRequest, + TransactionParams, } from '../types'; export enum ErrorCode { @@ -62,7 +62,7 @@ export async function validateTransactionOrigin({ selectedAddress?: string; txParams: TransactionParams; type?: TransactionType; -}) { +}): Promise { const isInternal = !origin || origin === ORIGIN_METAMASK; if (isInternal) { @@ -111,7 +111,7 @@ export function validateTxParams( txParams: TransactionParams, isEIP1559Compatible = true, chainId?: Hex, -) { +): void { validateEnvelopeType(txParams.type); validateEIP1559Compatibility(txParams, isEIP1559Compatible); validateParamFrom(txParams.from); @@ -129,7 +129,7 @@ export function validateTxParams( * @param type - The transaction envelope type to validate. * @throws Throws invalid params if the type is not a valid transaction envelope type. */ -function validateEnvelopeType(type: string | undefined) { +function validateEnvelopeType(type: string | undefined): void { if ( type && !Object.values(TransactionEnvelopeType).includes( @@ -154,7 +154,7 @@ function validateEnvelopeType(type: string | undefined) { function validateEIP1559Compatibility( txParams: TransactionParams, isEIP1559Compatible: boolean, -) { +): void { if (isEIP1559Transaction(txParams) && !isEIP1559Compatible) { throw rpcErrors.invalidParams( 'Invalid transaction params: params specify an EIP-1559 transaction but the current network does not support EIP-1559', @@ -173,7 +173,7 @@ function validateEIP1559Compatibility( * - If the value contains a decimal point (.), it is considered invalid. * - If the value is not a finite number, is NaN, or is not a safe integer, it is considered invalid. */ -function validateParamValue(value?: string) { +function validateParamValue(value?: string): void { if (value !== undefined) { if (value.includes('-')) { throw rpcErrors.invalidParams( @@ -209,7 +209,7 @@ function validateParamValue(value?: string) { * the "to" field is removed from the transaction parameters. * - If the recipient address is not a valid hexadecimal Ethereum address, an error is thrown. */ -function validateParamRecipient(txParams: TransactionParams) { +function validateParamRecipient(txParams: TransactionParams): void { if (txParams.to === '0x' || txParams.to === undefined) { if (txParams.data) { delete txParams.to; @@ -230,7 +230,7 @@ function validateParamRecipient(txParams: TransactionParams) { * the "to" field is removed from the transaction parameters. * - If the recipient address is not a valid hexadecimal Ethereum address, an error is thrown. */ -function validateParamFrom(from: string) { +function validateParamFrom(from: string): void { if (!from || typeof from !== 'string') { throw rpcErrors.invalidParams( `Invalid "from" address ${from}: not a string.`, @@ -247,7 +247,7 @@ function validateParamFrom(from: string) { * @param to - The to property to validate. * @throws Throws an error if the recipient address is invalid. */ -export function validateParamTo(to?: string) { +export function validateParamTo(to?: string): void { if (!to || typeof to !== 'string') { throw rpcErrors.invalidParams(`Invalid "to" address`); } @@ -269,7 +269,7 @@ export function validateBatchRequest({ internalAccounts: string[]; request: TransactionBatchRequest; sizeLimit: number; -}) { +}): void { const { origin } = request; const isExternal = origin && origin !== ORIGIN_METAMASK; @@ -312,7 +312,7 @@ export function validateBatchRequest({ * @param value - The input data to validate. * @throws Throws invalid params if the input data is invalid. */ -function validateParamData(value?: string) { +function validateParamData(value?: string): void { if (value) { const ERC20Interface = new Interface(abiERC20); try { @@ -335,7 +335,10 @@ function validateParamData(value?: string) { * @param chainIdParams - The chain ID to validate. * @param chainIdNetworkClient - The chain ID of the network client. */ -function validateParamChainId(chainIdParams?: Hex, chainIdNetworkClient?: Hex) { +function validateParamChainId( + chainIdParams?: Hex, + chainIdNetworkClient?: Hex, +): void { if ( chainIdParams && chainIdNetworkClient && @@ -352,7 +355,7 @@ function validateParamChainId(chainIdParams?: Hex, chainIdNetworkClient?: Hex) { * * @param txParams - The transaction parameters to validate. */ -function validateGasFeeParams(txParams: TransactionParams) { +function validateGasFeeParams(txParams: TransactionParams): void { if (txParams.gasPrice) { ensureProperTransactionEnvelopeTypeProvided(txParams, 'gasPrice'); ensureMutuallyExclusiveFieldsNotProvided( @@ -413,7 +416,7 @@ function validateGasFeeParams(txParams: TransactionParams) { function ensureProperTransactionEnvelopeTypeProvided( txParams: TransactionParams, field: keyof TransactionParams, -) { +): void { const type = txParams.type as TransactionEnvelopeType | undefined; switch (field) { @@ -426,12 +429,7 @@ function ensureProperTransactionEnvelopeTypeProvided( break; case 'maxFeePerGas': case 'maxPriorityFeePerGas': - if ( - type && - !TRANSACTION_ENVELOPE_TYPES_FEE_MARKET.includes( - type as TransactionEnvelopeType, - ) - ) { + if (type && !TRANSACTION_ENVELOPE_TYPES_FEE_MARKET.includes(type)) { throw rpcErrors.invalidParams( `Invalid transaction envelope type: specified type "${type}" but including maxFeePerGas and maxPriorityFeePerGas requires type: "${TRANSACTION_ENVELOPE_TYPES_FEE_MARKET.join(', ')}"`, ); @@ -439,12 +437,7 @@ function ensureProperTransactionEnvelopeTypeProvided( break; case 'gasPrice': default: - if ( - type && - TRANSACTION_ENVELOPE_TYPES_FEE_MARKET.includes( - type as TransactionEnvelopeType, - ) - ) { + if (type && TRANSACTION_ENVELOPE_TYPES_FEE_MARKET.includes(type)) { throw rpcErrors.invalidParams( `Invalid transaction envelope type: specified type "${type}" but included a gasPrice instead of maxFeePerGas and maxPriorityFeePerGas`, ); @@ -466,7 +459,7 @@ function ensureMutuallyExclusiveFieldsNotProvided( txParams: TransactionParams, fieldBeingValidated: GasFieldsToValidate, mutuallyExclusiveField: GasFieldsToValidate, -) { +): void { if (typeof txParams[mutuallyExclusiveField] !== 'undefined') { throw rpcErrors.invalidParams( `Invalid transaction params: specified ${fieldBeingValidated} but also included ${mutuallyExclusiveField}, these cannot be mixed`, @@ -482,7 +475,10 @@ function ensureMutuallyExclusiveFieldsNotProvided( * @param field - The current field being validated * @throws {rpcErrors.invalidParams} Throws if field is not a valid hexadecimal */ -function ensureFieldIsValidHex(data: T, field: keyof T) { +function ensureFieldIsValidHex( + data: DataType, + field: keyof DataType, +): void { const value = data[field]; if (typeof value !== 'string' || !isStrictHexString(value)) { throw rpcErrors.invalidParams( @@ -498,7 +494,7 @@ function ensureFieldIsValidHex(data: T, field: keyof T) { * * @param txParams - The transaction parameters containing the authorization list to validate. */ -function validateAuthorizationList(txParams: TransactionParams) { +function validateAuthorizationList(txParams: TransactionParams): void { const { authorizationList } = txParams; if (!authorizationList) { @@ -523,7 +519,7 @@ function validateAuthorizationList(txParams: TransactionParams) { * * @param authorization - The authorization object to validate. */ -function validateAuthorization(authorization: Authorization) { +function validateAuthorization(authorization: Authorization): void { ensureFieldIsValidHex(authorization, 'address'); validateHexLength(authorization.address, 20, 'address'); @@ -553,7 +549,7 @@ function validateHexLength( value: string, lengthBytes: number, fieldName: string, -) { +): void { const actualLengthBytes = remove0x(value).length / 2; if (actualLengthBytes !== lengthBytes) { diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 9b4d8ad0245..8c1b150a5dc 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,85 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [10.4.0] + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.4.0` to `^62.5.0` ([#7325](https://github.com/MetaMask/core/pull/7325)) +- Bump `@metamask/bridge-status-controller` from `^63.1.0` to `^64.0.1` ([#7295](https://github.com/MetaMask/core/pull/7295), [#7307](https://github.com/MetaMask/core/pull/7307)) +- Bump `@metamask/bridge-controller` from `^63.2.0` to `^64.0.0` ([#7295](https://github.com/MetaMask/core/pull/7295)) +- Skip delegation in Relay quotes if token transfer only ([#7262](https://github.com/MetaMask/core/pull/7262)) +- Bump `@metamask/assets-controllers` from `^92.0.0` to `^93.1.0` ([#7291](https://github.com/MetaMask/core/pull/7291), [#7309](https://github.com/MetaMask/core/pull/7309)) +- Bump `@metamask/remote-feature-flag-controller` from `^2.0.1` to `^3.0.0` ([#7309](https://github.com/MetaMask/core/pull/7309) + +### Fixed + +- Fix source network fees for batch Relay deposits on EIP-7702 networks ([#7323](https://github.com/MetaMask/core/pull/7323)) +- Improve Relay provider fees ([#7313](https://github.com/MetaMask/core/pull/7313)) + - Include slippage from feature flag. + - Read fee from `totalImpact` property. + - Send dust in transaction quotes to token transfer recipient, if available. + +## [10.3.0] + +### Changed + +- Use `overwriteUpgrade` when adding transaction batches in Relay strategy ([#7282](https://github.com/MetaMask/core/pull/7282)) +- Bump `@metamask/network-controller` from `^26.0.0` to `^27.0.0` ([#7258](https://github.com/MetaMask/core/pull/7258)) +- Bump `@metamask/transaction-controller` from `^62.3.1` to `^62.4.0` ([#7289](https://github.com/MetaMask/core/pull/7289)) + +### Fixed + +- Include `authorizationList` in Relay deposit tranasction if source and target chain are the same, and EIP-7702 upgrade is needed ([#7281](https://github.com/MetaMask/core/pull/7281)) + +## [10.2.0] + +### Added + +- Use `relayDisabledGasStationChains` feature flag to disable gas station on specific source chains in Relay strategy ([#7255](https://github.com/MetaMask/core/pull/7255)) + +### Changed + +- Bump `@metamask/assets-controllers` from `^91.0.0` to `^92.0.0` ([#7253](https://github.com/MetaMask/core/pull/7253)) +- Bump `@metamask/bridge-status-controller` from `^63.0.0` to `^63.1.0` ([#7245](https://github.com/MetaMask/core/pull/7245)) +- Bump `@metamask/transaction-controller` from `^62.2.0` to `^62.3.1` ([#7236](https://github.com/MetaMask/core/pull/7236), [#7257](https://github.com/MetaMask/core/pull/7257)) +- Bump `@metamask/bridge-controller` from `^63.0.0` to `^63.2.0` ([#7238](https://github.com/MetaMask/core/pull/7238), [#7245](https://github.com/MetaMask/core/pull/7245)) + +## [10.1.0] + +### Added + +- Use new feature flags to configure gas limit fallback for Relay quotes ([#7229](https://github.com/MetaMask/core/pull/7229)) + - Use gas fee properties from Relay quotes. + +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7220](https://github.com/MetaMask/core/pull/7220)) + - The dependencies moved are: + - `@metamask/assets-controllers` (^91.0.0) + - `@metamask/bridge-controller` (^63.0.0) + - `@metamask/bridge-status-controller` (^63.0.0) + - `@metamask/gas-fee-controller` (^26.0.0) + - `@metamask/network-controller` (^26.0.0) + - `@metamask/remote-feature-flag-controller` (^2.0.1) + - `@metamask/transaction-controller` (^62.2.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + +## [10.0.0] + +### Added + +- Use gas fee token for Relay deposit transactions if insufficient native balance ([#7193](https://github.com/MetaMask/core/pull/7193)) + - Add optional `fees.isSourceGasFeeToken` property to `TransactionPayQuote` and `TransactionPayTotals` type. + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-status-controller` from `^62.0.0` to `^63.0.0` ([#7207](https://github.com/MetaMask/core/pull/7207)) +- **BREAKING:** Bump `@metamask/bridge-controller` from `^62.0.0` to `^63.0.0` ([#7207](https://github.com/MetaMask/core/pull/7207)) +- **BREAKING:** Bump `@metamask/assets-controllers` from `^90.0.0` to `^91.0.0` ([#7207](https://github.com/MetaMask/core/pull/7207)) + ## [9.0.0] ### Changed @@ -124,7 +203,12 @@ 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@9.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@10.4.0...HEAD +[10.4.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@10.3.0...@metamask/transaction-pay-controller@10.4.0 +[10.3.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@10.2.0...@metamask/transaction-pay-controller@10.3.0 +[10.2.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@10.1.0...@metamask/transaction-pay-controller@10.2.0 +[10.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@10.0.0...@metamask/transaction-pay-controller@10.1.0 +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@9.0.0...@metamask/transaction-pay-controller@10.0.0 [9.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@8.0.0...@metamask/transaction-pay-controller@9.0.0 [8.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@7.0.0...@metamask/transaction-pay-controller@8.0.0 [7.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@6.0.0...@metamask/transaction-pay-controller@7.0.0 diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index 66ebce49b31..dd976ad21de 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": "9.0.0", + "version": "10.4.0", "description": "Manages alternate payment strategies to provide required funds for transactions in MetaMask", "keywords": [ "MetaMask", @@ -51,10 +51,17 @@ "dependencies": { "@ethersproject/abi": "^5.7.0", "@ethersproject/contracts": "^5.7.0", + "@metamask/assets-controllers": "^93.1.0", "@metamask/base-controller": "^9.0.0", + "@metamask/bridge-controller": "^64.0.0", + "@metamask/bridge-status-controller": "^64.0.1", "@metamask/controller-utils": "^11.16.0", + "@metamask/gas-fee-controller": "^26.0.0", "@metamask/messenger": "^0.3.0", "@metamask/metamask-eth-abis": "^3.1.1", + "@metamask/network-controller": "^27.0.0", + "@metamask/remote-feature-flag-controller": "^3.0.0", + "@metamask/transaction-controller": "^62.5.0", "@metamask/utils": "^11.8.1", "bignumber.js": "^9.1.2", "bn.js": "^5.2.1", @@ -62,14 +69,7 @@ "lodash": "^4.17.21" }, "devDependencies": { - "@metamask/assets-controllers": "^90.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/bridge-controller": "^62.0.0", - "@metamask/bridge-status-controller": "^62.0.0", - "@metamask/gas-fee-controller": "^26.0.0", - "@metamask/network-controller": "^26.0.0", - "@metamask/remote-feature-flag-controller": "^2.0.1", - "@metamask/transaction-controller": "^62.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -79,15 +79,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, - "peerDependencies": { - "@metamask/assets-controllers": "^90.0.0", - "@metamask/bridge-controller": "^62.0.0", - "@metamask/bridge-status-controller": "^62.0.0", - "@metamask/gas-fee-controller": "^26.0.0", - "@metamask/network-controller": "^26.0.0", - "@metamask/remote-feature-flag-controller": "^2.0.0", - "@metamask/transaction-controller": "^62.0.0" - }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/transaction-pay-controller/src/actions/update-payment-token.ts b/packages/transaction-pay-controller/src/actions/update-payment-token.ts index 80947911662..1355be22dce 100644 --- a/packages/transaction-pay-controller/src/actions/update-payment-token.ts +++ b/packages/transaction-pay-controller/src/actions/update-payment-token.ts @@ -1,4 +1,5 @@ -import { createModuleLogger, type Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import type { TransactionPayControllerMessenger } from '..'; diff --git a/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts index 873c7916c1b..779b84c1188 100644 --- a/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts @@ -1,4 +1,5 @@ -import { FeatureId, type QuoteResponse } from '@metamask/bridge-controller'; +import { FeatureId } from '@metamask/bridge-controller'; +import type { QuoteResponse } from '@metamask/bridge-controller'; import type { TxData } from '@metamask/bridge-controller'; import { TransactionType } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; @@ -16,7 +17,7 @@ import type { PayStrategyGetQuotesRequest, TransactionPayQuote, } from '../../types'; -import { type QuoteRequest } from '../../types'; +import type { QuoteRequest } from '../../types'; import { calculateGasCost, calculateTransactionGasCost } from '../../utils/gas'; import { getTokenFiatRate } from '../../utils/token'; diff --git a/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.ts b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.ts index 3e6ee786581..93153a84f1f 100644 --- a/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.ts @@ -1,7 +1,5 @@ -import { - FeatureId, - type GenericQuoteRequest, -} from '@metamask/bridge-controller'; +import { FeatureId } from '@metamask/bridge-controller'; +import type { GenericQuoteRequest } from '@metamask/bridge-controller'; import type { TxData } from '@metamask/bridge-controller'; import type { QuoteResponse } from '@metamask/bridge-controller'; import { toChecksumHexAddress, toHex } from '@metamask/controller-utils'; diff --git a/packages/transaction-pay-controller/src/strategy/relay/constants.ts b/packages/transaction-pay-controller/src/strategy/relay/constants.ts index b176d7b73f6..2ecf471bd86 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/constants.ts @@ -1,5 +1,4 @@ export const CHAIN_ID_HYPERCORE = '0x539'; export const RELAY_URL_BASE = 'https://api.relay.link'; -export const RELAY_URL_QUOTE = `${RELAY_URL_BASE}/quote`; -export const RELAY_FALLBACK_GAS_LIMIT = 900000; export const RELAY_POLLING_INTERVAL = 1000; // 1 Second +export const TOKEN_TRANSFER_FOUR_BYTE = '0xa9059cbb'; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 1730061e912..a9017c835dc 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -1,9 +1,12 @@ -import { successfulFetch } from '@metamask/controller-utils'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import { successfulFetch, toHex } from '@metamask/controller-utils'; +import type { + GasFeeToken, + TransactionMeta, +} from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { cloneDeep } from 'lodash'; -import { RELAY_URL_QUOTE } from './constants'; +import { CHAIN_ID_HYPERCORE } from './constants'; import { getRelayQuotes } from './relay-quotes'; import type { RelayQuote } from './types'; import { @@ -17,8 +20,17 @@ import type { GetDelegationTransactionCallback, QuoteRequest, } from '../../types'; -import { calculateGasCost, calculateTransactionGasCost } from '../../utils/gas'; -import { getNativeToken, getTokenFiatRate } from '../../utils/token'; +import { DEFAULT_RELAY_QUOTE_URL } from '../../utils/feature-flags'; +import { + calculateGasCost, + calculateGasFeeTokenCost, + calculateTransactionGasCost, +} from '../../utils/gas'; +import { + getNativeToken, + getTokenBalance, + getTokenFiatRate, +} from '../../utils/token'; jest.mock('../../utils/token'); jest.mock('../../utils/gas'); @@ -28,6 +40,11 @@ jest.mock('@metamask/controller-utils', () => ({ successfulFetch: jest.fn(), })); +const TRANSACTION_META_MOCK = { txParams: {} } as TransactionMeta; +const TOKEN_TRANSFER_RECIPIENT_MOCK = + '0x5678901234567890123456789012345678901234'; +const NESTED_TRANSACTION_DATA_MOCK = '0xdef' as Hex; + const QUOTE_REQUEST_MOCK: QuoteRequest = { from: '0x1234567890123456789012345678901234567891', sourceBalanceRaw: '10000000000000000000', @@ -53,6 +70,9 @@ const QUOTE_MOCK = { minimumAmount: '125', }, timeEstimate: 300, + totalImpact: { + usd: '1.11', + }, }, fees: { relayer: { @@ -85,8 +105,6 @@ const QUOTE_MOCK = { ], } as RelayQuote; -const TRANSACTION_META_MOCK = { txParams: {} } as TransactionMeta; - const DELEGATION_RESULT_MOCK = { authorizationList: [ { @@ -100,11 +118,22 @@ const DELEGATION_RESULT_MOCK = { value: '0x333' as Hex, } as Awaited>; +const GAS_FEE_TOKEN_MOCK = { + amount: toHex(1230000), + gas: toHex(21000), + tokenAddress: '0xabc' as Hex, +} as GasFeeToken; + +const TOKEN_TRANSFER_DATA_MOCK = + '0xa9059cbb0000000000000000000000005678901234567890123456789012345678901234000000000000000000000000000000000000000000000000000000000000007b' as Hex; + describe('Relay Quotes Utils', () => { const successfulFetchMock = jest.mocked(successfulFetch); const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); const calculateGasCostMock = jest.mocked(calculateGasCost); + const calculateGasFeeTokenCostMock = jest.mocked(calculateGasFeeTokenCost); const getNativeTokenMock = jest.mocked(getNativeToken); + const getTokenBalanceMock = jest.mocked(getTokenBalance); const calculateTransactionGasCostMock = jest.mocked( calculateTransactionGasCost, @@ -113,6 +142,7 @@ describe('Relay Quotes Utils', () => { const { messenger, getDelegationTransactionMock, + getGasFeeTokensMock, getRemoteFeatureFlagControllerStateMock, } = getMessengerMock(); @@ -138,12 +168,20 @@ describe('Relay Quotes Utils', () => { usd: '3.45', }); + calculateGasFeeTokenCostMock.mockReturnValue({ + fiat: '5.56', + human: '2.725', + raw: '2725000000000000', + usd: '4.45', + }); + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ cacheTimestamp: 0, remoteFeatureFlags: {}, }); getDelegationTransactionMock.mockResolvedValue(DELEGATION_RESULT_MOCK); + getGasFeeTokensMock.mockResolvedValue([]); }); describe('getRelayQuotes', () => { @@ -177,18 +215,24 @@ describe('Relay Quotes Utils', () => { }); expect(successfulFetchMock).toHaveBeenCalledWith( - RELAY_URL_QUOTE, + DEFAULT_RELAY_QUOTE_URL, + expect.anything(), + ); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body).toStrictEqual( expect.objectContaining({ - body: JSON.stringify({ - amount: QUOTE_REQUEST_MOCK.targetAmountMinimum, - destinationChainId: 2, - destinationCurrency: QUOTE_REQUEST_MOCK.targetTokenAddress, - originChainId: 1, - originCurrency: QUOTE_REQUEST_MOCK.sourceTokenAddress, - recipient: QUOTE_REQUEST_MOCK.from, - tradeType: 'EXPECTED_OUTPUT', - user: QUOTE_REQUEST_MOCK.from, - }), + amount: QUOTE_REQUEST_MOCK.targetAmountMinimum, + destinationChainId: 2, + destinationCurrency: QUOTE_REQUEST_MOCK.targetTokenAddress, + originChainId: 1, + originCurrency: QUOTE_REQUEST_MOCK.sourceTokenAddress, + recipient: QUOTE_REQUEST_MOCK.from, + tradeType: 'EXACT_OUTPUT', + user: QUOTE_REQUEST_MOCK.from, }), ); }); @@ -239,6 +283,262 @@ describe('Relay Quotes Utils', () => { ); }); + it('includes request in quote', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].original.request).toStrictEqual({ + amount: QUOTE_REQUEST_MOCK.targetAmountMinimum, + authorizationList: expect.any(Array), + destinationChainId: 2, + destinationCurrency: QUOTE_REQUEST_MOCK.targetTokenAddress, + originChainId: 1, + originCurrency: QUOTE_REQUEST_MOCK.sourceTokenAddress, + recipient: QUOTE_REQUEST_MOCK.from, + slippageTolerance: '50', + tradeType: 'EXACT_OUTPUT', + txs: expect.any(Array), + user: QUOTE_REQUEST_MOCK.from, + }); + }); + + it('skips delegation for token transfers', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + data: TOKEN_TRANSFER_DATA_MOCK, + }, + } as TransactionMeta, + }); + + expect(getDelegationTransactionMock).not.toHaveBeenCalled(); + }); + + it('extracts recipient from token transfer', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + data: TOKEN_TRANSFER_DATA_MOCK, + }, + } as TransactionMeta, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.recipient).toBe(TOKEN_TRANSFER_RECIPIENT_MOCK.toLowerCase()); + }); + + it('includes transactions from nested transactions', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + nestedTransactions: [ + { + data: NESTED_TRANSACTION_DATA_MOCK, + }, + ], + txParams: { + data: '0xabc' as Hex, + }, + } as TransactionMeta, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body).toStrictEqual( + expect.objectContaining({ + authorizationList: [ + { + chainId: 1, + nonce: 2, + yParity: 1, + }, + ], + recipient: QUOTE_REQUEST_MOCK.from, + tradeType: 'EXACT_OUTPUT', + txs: [ + { + to: QUOTE_REQUEST_MOCK.targetTokenAddress, + data: '0xa9059cbb0000000000000000000000001234567890123456789012345678901234567891000000000000000000000000000000000000000000000000000000000000007b', + value: '0x0', + }, + { + to: DELEGATION_RESULT_MOCK.to, + data: DELEGATION_RESULT_MOCK.data, + value: DELEGATION_RESULT_MOCK.value, + }, + ], + }), + ); + }); + + it('skips delegation for token transfers in nested transactions', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + nestedTransactions: [ + { + data: TOKEN_TRANSFER_DATA_MOCK, + }, + ], + txParams: { + data: '0xabc' as Hex, + }, + } as TransactionMeta, + }); + + expect(getDelegationTransactionMock).not.toHaveBeenCalled(); + }); + + it('extracts recipient from token transfer in nested transactions', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + nestedTransactions: [ + { + data: TOKEN_TRANSFER_DATA_MOCK, + }, + ], + txParams: { + data: '0xabc' as Hex, + }, + } as TransactionMeta, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.recipient).toBe(TOKEN_TRANSFER_RECIPIENT_MOCK.toLowerCase()); + }); + + it('extracts recipient and sets refundTo when nested transactions include token transfer with delegation', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + nestedTransactions: [ + { + data: NESTED_TRANSACTION_DATA_MOCK, + }, + { + data: TOKEN_TRANSFER_DATA_MOCK, + }, + ], + txParams: { + data: '0xabc' as Hex, + }, + } as TransactionMeta, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.recipient).toBe(TOKEN_TRANSFER_RECIPIENT_MOCK); + expect(body.refundTo).toBe(QUOTE_REQUEST_MOCK.from); + }); + + it('skips delegation for Hypercore deposits', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetChainId: CHAIN_ID_HYPERCORE, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + data: '0xabc' as Hex, + }, + } as TransactionMeta, + }); + + expect(getDelegationTransactionMock).not.toHaveBeenCalled(); + }); + + it('does not extract recipient for Hypercore deposits with token transfer signature', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetChainId: CHAIN_ID_HYPERCORE, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + data: TOKEN_TRANSFER_DATA_MOCK, + }, + } as TransactionMeta, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.recipient).toBe(QUOTE_REQUEST_MOCK.from); + }); + it('sends request to url from feature flag', async () => { successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, @@ -295,7 +595,7 @@ describe('Relay Quotes Utils', () => { expect(result[0].estimatedDuration).toBe(300); }); - it('includes provider fee from relayer fee', async () => { + it('includes provider fee', async () => { successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, } as never); @@ -312,26 +612,6 @@ describe('Relay Quotes Utils', () => { }); }); - it('includes provider fee from usd change if greater', async () => { - const quote = cloneDeep(QUOTE_MOCK); - quote.details.currencyIn.amountUsd = '3.00'; - - successfulFetchMock.mockResolvedValue({ - json: async () => quote, - } as never); - - const result = await getRelayQuotes({ - messenger, - requests: [QUOTE_REQUEST_MOCK], - transaction: TRANSACTION_META_MOCK, - }); - - expect(result[0].fees.provider).toStrictEqual({ - usd: '1.77', - fiat: '3.54', - }); - }); - it('includes dust in quote', async () => { successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, @@ -349,89 +629,314 @@ describe('Relay Quotes Utils', () => { }); }); - it('includes source network fee in quote', async () => { - successfulFetchMock.mockResolvedValue({ - json: async () => QUOTE_MOCK, - } as never); + describe('includes source network fee', () => { + it('in quote', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); - const result = await getRelayQuotes({ - messenger, - requests: [QUOTE_REQUEST_MOCK], - transaction: TRANSACTION_META_MOCK, + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.sourceNetwork).toStrictEqual({ + estimate: { + fiat: '4.56', + human: '1.725', + raw: '1725000000000000', + usd: '3.45', + }, + max: { + fiat: '4.56', + human: '1.725', + raw: '1725000000000000', + usd: '3.45', + }, + }); }); - expect(result[0].fees.sourceNetwork).toStrictEqual({ - estimate: { - fiat: '4.56', - human: '1.725', - raw: '1725000000000000', - usd: '3.45', - }, - max: { - fiat: '4.56', - human: '1.725', - raw: '1725000000000000', - usd: '3.45', - }, + it('using fallback if gas missing', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + delete quoteMock.steps[0].items[0].data.gas; + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ gas: 900000 }), + ); }); - }); - it('includes source network fee in quote using fallback if gas missing', async () => { - const quoteMock = cloneDeep(QUOTE_MOCK); - delete quoteMock.steps[0].items[0].data.gas; + it('using gas total from multiple transactions', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); - successfulFetchMock.mockResolvedValue({ - json: async () => quoteMock, - } as never); + quoteMock.steps[0].items.push({ + data: { + gas: '480000', + }, + } as never); - await getRelayQuotes({ - messenger, - requests: [QUOTE_REQUEST_MOCK], - transaction: TRANSACTION_META_MOCK, + quoteMock.steps.push({ + items: [ + { + data: { + gas: '1000', + }, + }, + { + data: { + gas: '2000', + }, + }, + ], + } as never); + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ gas: 504000 }), + ); }); - expect(calculateGasCostMock).toHaveBeenCalledWith( - expect.objectContaining({ gas: 900000 }), - ); - }); + it('using gas fee token cost if insufficient native balance', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); - it('includes source network fee using gas total from multiple transactions', async () => { - const quoteMock = cloneDeep(QUOTE_MOCK); + getTokenBalanceMock.mockReturnValue('1724999999999999'); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); - quoteMock.steps[0].items.push({ - data: { - gas: '480000', - }, - } as never); + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.isSourceGasFeeToken).toBe(true); + expect(result[0].fees.sourceNetwork).toStrictEqual({ + estimate: { + fiat: '5.56', + human: '2.725', + raw: '2725000000000000', + usd: '4.45', + }, + max: { + fiat: '5.56', + human: '2.725', + raw: '2725000000000000', + usd: '4.45', + }, + }); + }); - quoteMock.steps.push({ - items: [ - { - data: { - gas: '1000', - }, + it('using estimated gas fee token cost if insufficient native balance and batch', async () => { + const quote = cloneDeep(QUOTE_MOCK); + + quote.steps[0].items.push({ + data: { + gas: '21000', }, - { - data: { - gas: '2000', + } as never); + + successfulFetchMock.mockResolvedValue({ + json: async () => quote, + } as never); + + getTokenBalanceMock.mockReturnValue('1724999999999999'); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(calculateGasFeeTokenCostMock).toHaveBeenCalledWith( + expect.objectContaining({ + gasFeeToken: { + ...GAS_FEE_TOKEN_MOCK, + amount: toHex(1230000 * 2), }, + }), + ); + }); + + it('not using gas fee token if sufficient native balance', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + getTokenBalanceMock.mockReturnValue('1725000000000000'); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.isSourceGasFeeToken).toBeUndefined(); + expect(result[0].fees.sourceNetwork).toStrictEqual({ + estimate: { + fiat: '4.56', + human: '1.725', + raw: '1725000000000000', + usd: '3.45', }, - ], - } as never); + max: { + fiat: '4.56', + human: '1.725', + raw: '1725000000000000', + usd: '3.45', + }, + }); + }); - successfulFetchMock.mockResolvedValue({ - json: async () => quoteMock, - } as never); + it('not using gas fee token if source token not found', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); - await getRelayQuotes({ - messenger, - requests: [QUOTE_REQUEST_MOCK], - transaction: TRANSACTION_META_MOCK, + getTokenBalanceMock.mockReturnValue('1724999999999999'); + getGasFeeTokensMock.mockResolvedValue([ + { ...GAS_FEE_TOKEN_MOCK, tokenAddress: '0xdef' as Hex }, + ]); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.isSourceGasFeeToken).toBeUndefined(); + expect(result[0].fees.sourceNetwork).toStrictEqual({ + estimate: { + fiat: '4.56', + human: '1.725', + raw: '1725000000000000', + usd: '3.45', + }, + max: { + fiat: '4.56', + human: '1.725', + raw: '1725000000000000', + usd: '3.45', + }, + }); }); - expect(calculateGasCostMock).toHaveBeenCalledWith( - expect.objectContaining({ gas: 504000 }), - ); + it('not using gas fee token if calculation fails', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + getTokenBalanceMock.mockReturnValue('1724999999999999'); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + calculateGasFeeTokenCostMock.mockReturnValue(undefined); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.isSourceGasFeeToken).toBeUndefined(); + expect(result[0].fees.sourceNetwork).toStrictEqual({ + estimate: { + fiat: '4.56', + human: '1.725', + raw: '1725000000000000', + usd: '3.45', + }, + max: { + fiat: '4.56', + human: '1.725', + raw: '1725000000000000', + usd: '3.45', + }, + }); + }); + + it('using gas fee token cost with normalized value', async () => { + const quote = cloneDeep(QUOTE_MOCK); + quote.steps[0].items[0].data.value = undefined as never; + + successfulFetchMock.mockResolvedValue({ + json: async () => quote, + } as never); + + getTokenBalanceMock.mockReturnValue('1724999999999999'); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(getGasFeeTokensMock).toHaveBeenCalledWith( + expect.objectContaining({ + value: '0x0', + }), + ); + }); + + it('not using gas fee token cost if chain disabled in feature flag', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + getTokenBalanceMock.mockReturnValue('1724999999999999'); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + cacheTimestamp: 0, + remoteFeatureFlags: { + confirmations_pay: { + relayDisabledGasStationChains: [QUOTE_REQUEST_MOCK.sourceChainId], + }, + }, + }); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.isSourceGasFeeToken).toBeUndefined(); + expect(result[0].fees.sourceNetwork).toStrictEqual({ + estimate: { + fiat: '4.56', + human: '1.725', + raw: '1725000000000000', + usd: '3.45', + }, + max: { + fiat: '4.56', + human: '1.725', + raw: '1725000000000000', + usd: '3.45', + }, + }); + }); }); it('includes target network fee in quote', async () => { diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 8d3bc7bb88d..57475fcd962 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -1,15 +1,11 @@ import { Interface } from '@ethersproject/abi'; -import { successfulFetch } from '@metamask/controller-utils'; -import type { Json } from '@metamask/utils'; +import { successfulFetch, toHex } from '@metamask/controller-utils'; +import type { Hex, Json } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { - CHAIN_ID_HYPERCORE, - RELAY_FALLBACK_GAS_LIMIT, - RELAY_URL_QUOTE, -} from './constants'; -import type { RelayQuote } from './types'; +import { CHAIN_ID_HYPERCORE, TOKEN_TRANSFER_FOUR_BYTE } from './constants'; +import type { RelayQuote, RelayQuoteRequest } from './types'; import { TransactionPayStrategy } from '../..'; import type { TransactionMeta } from '../../../../transaction-controller/src'; import { @@ -27,8 +23,13 @@ import type { TransactionPayControllerMessenger, TransactionPayQuote, } from '../../types'; -import { calculateGasCost } from '../../utils/gas'; -import { getNativeToken, getTokenFiatRate } from '../../utils/token'; +import { getFeatureFlags } from '../../utils/feature-flags'; +import { calculateGasCost, calculateGasFeeTokenCost } from '../../utils/gas'; +import { + getNativeToken, + getTokenBalance, + getTokenFiatRate, +} from '../../utils/token'; const log = createModuleLogger(projectLogger, 'relay-strategy'); @@ -74,15 +75,21 @@ async function getSingleQuote( fullRequest: PayStrategyGetQuotesRequest, ): Promise> { const { messenger, transaction } = fullRequest; + const { slippage: slippageDecimal } = getFeatureFlags(messenger); + + const slippageTolerance = new BigNumber(slippageDecimal * 100 * 100).toFixed( + 0, + ); try { - const body = { + const body: RelayQuoteRequest = { amount: request.targetAmountMinimum, destinationChainId: Number(request.targetChainId), destinationCurrency: request.targetTokenAddress, originChainId: Number(request.sourceChainId), originCurrency: request.sourceTokenAddress, recipient: request.from, + slippageTolerance, tradeType: 'EXPECTED_OUTPUT', user: request.from, }; @@ -100,6 +107,7 @@ async function getSingleQuote( }); const quote = (await response.json()) as RelayQuote; + quote.request = body; log('Fetched relay quote', quote); @@ -124,16 +132,27 @@ async function processTransactions( requestBody: Record, messenger: TransactionPayControllerMessenger, ) { - const { data, value } = transaction.txParams; + const { nestedTransactions, txParams } = transaction; + const data = txParams?.data as Hex | undefined; + + const singleData = + nestedTransactions?.length === 1 ? nestedTransactions[0].data : data; + + const isHypercore = request.targetChainId === CHAIN_ID_HYPERCORE; - /* istanbul ignore next */ - const hasNoParams = (!data || data === '0x') && (!value || value === '0x0'); + const isTokenTransfer = + !isHypercore && singleData?.startsWith(TOKEN_TRANSFER_FOUR_BYTE); - const skipDelegation = - hasNoParams || request.targetChainId === CHAIN_ID_HYPERCORE; + if (isTokenTransfer) { + requestBody.recipient = getTransferRecipient(singleData as Hex); + + log('Updating recipient as token transfer', requestBody.recipient); + } + + const skipDelegation = isTokenTransfer || isHypercore; if (skipDelegation) { - log('Skipping delegation as no transaction data'); + log('Skipping delegation as token transfer or Hypercore deposit'); return; } @@ -151,20 +170,24 @@ async function processTransactions( }), ); - const tokenTransferData = new Interface([ - 'function transfer(address to, uint256 amount)', - ]).encodeFunctionData('transfer', [ - request.from, - request.targetAmountMinimum, - ]); - requestBody.authorizationList = normalizedAuthorizationList; requestBody.tradeType = 'EXACT_OUTPUT'; + const tokenTransferData = nestedTransactions?.find((t) => + t.data?.startsWith(TOKEN_TRANSFER_FOUR_BYTE), + )?.data; + + // If the transactions include a token transfer, change the recipient + // so any extra dust is also sent to the same address, rather than back to the user. + if (tokenTransferData) { + requestBody.recipient = getTransferRecipient(tokenTransferData); + requestBody.refundTo = request.from; + } + requestBody.txs = [ { to: request.targetTokenAddress, - data: tokenTransferData, + data: buildTokenTransferData(request.from, request.targetAmountMinimum), value: '0x0', }, { @@ -182,6 +205,10 @@ async function processTransactions( * @returns Normalized request. */ function normalizeRequest(request: QuoteRequest) { + const newRequest = { + ...request, + }; + const isHyperliquidDeposit = request.targetChainId === CHAIN_ID_ARBITRUM && request.targetTokenAddress.toLowerCase() === @@ -191,30 +218,24 @@ function normalizeRequest(request: QuoteRequest) { request.sourceChainId === CHAIN_ID_POLYGON && request.sourceTokenAddress === getNativeToken(request.sourceChainId); - const requestOutput: QuoteRequest = { - ...request, - sourceTokenAddress: isPolygonNativeSource - ? NATIVE_TOKEN_ADDRESS - : request.sourceTokenAddress, - targetChainId: isHyperliquidDeposit - ? CHAIN_ID_HYPERCORE - : request.targetChainId, - targetTokenAddress: isHyperliquidDeposit - ? '0x00000000000000000000000000000000' - : request.targetTokenAddress, - targetAmountMinimum: isHyperliquidDeposit - ? new BigNumber(request.targetAmountMinimum).shiftedBy(2).toString(10) - : request.targetAmountMinimum, - }; + if (isPolygonNativeSource) { + newRequest.sourceTokenAddress = NATIVE_TOKEN_ADDRESS; + } if (isHyperliquidDeposit) { + newRequest.targetChainId = CHAIN_ID_HYPERCORE; + newRequest.targetTokenAddress = '0x00000000000000000000000000000000'; + newRequest.targetAmountMinimum = new BigNumber(request.targetAmountMinimum) + .shiftedBy(2) + .toString(10); + log('Converting Arbitrum Hyperliquid deposit to direct deposit', { originalRequest: request, - normalizedRequest: requestOutput, + normalizedRequest: newRequest, }); } - return requestOutput; + return newRequest; } /** @@ -225,11 +246,11 @@ function normalizeRequest(request: QuoteRequest) { * @param fullRequest - Full quotes request. * @returns Normalized quote. */ -function normalizeQuote( +async function normalizeQuote( quote: RelayQuote, request: QuoteRequest, fullRequest: PayStrategyGetQuotesRequest, -): TransactionPayQuote { +): Promise> { const { messenger } = fullRequest; const { details } = quote; const { currencyIn } = details; @@ -246,7 +267,8 @@ function normalizeQuote( usdToFiatRate, ); - const sourceNetwork = calculateSourceNetworkCost(quote, messenger); + const { isGasFeeToken: isSourceGasFeeToken, ...sourceNetwork } = + await calculateSourceNetworkCost(quote, messenger, request); const targetNetwork = { usd: '0', @@ -263,6 +285,7 @@ function normalizeQuote( dust, estimatedDuration: details.timeEstimate, fees: { + isSourceGasFeeToken, provider, sourceNetwork, targetNetwork, @@ -350,66 +373,171 @@ function getFiatRates( return { sourceFiatRate, usdToFiatRate }; } -/** - * Gets feature flags for Relay quotes. - * - * @param messenger - Controller messenger. - * @returns Feature flags. - */ -function getFeatureFlags(messenger: TransactionPayControllerMessenger) { - const featureFlagState = messenger.call( - 'RemoteFeatureFlagController:getState', - ); - - const featureFlags = featureFlagState.remoteFeatureFlags - ?.confirmations_pay as Record | undefined; - - const relayQuoteUrl = featureFlags?.relayQuoteUrl ?? RELAY_URL_QUOTE; - - return { - relayQuoteUrl, - }; -} - /** * Calculates source network cost from a Relay quote. * * @param quote - Relay quote. * @param messenger - Controller messenger. + * @param request - Quote request. * @returns Total source network cost in USD and fiat. */ -function calculateSourceNetworkCost( +async function calculateSourceNetworkCost( quote: RelayQuote, messenger: TransactionPayControllerMessenger, -): TransactionPayQuote['fees']['sourceNetwork'] { + request: QuoteRequest, +): Promise< + TransactionPayQuote['fees']['sourceNetwork'] & { + isGasFeeToken?: boolean; + } +> { + const { from, sourceChainId, sourceTokenAddress } = request; const allParams = quote.steps.flatMap((s) => s.items).map((i) => i.data); - const { chainId } = allParams[0]; - const totalGasLimit = calculateSourceNetworkGasLimit(allParams); + const { relayDisabledGasStationChains } = getFeatureFlags(messenger); + + const { chainId, data, maxFeePerGas, maxPriorityFeePerGas, to, value } = + allParams[0]; + + const totalGasLimitEstimate = calculateSourceNetworkGasLimit( + allParams, + messenger, + { + isMax: false, + }, + ); + + const totalGasLimitMax = calculateSourceNetworkGasLimit( + allParams, + messenger, + { + isMax: true, + }, + ); + + log('Total gas limit', { totalGasLimitEstimate, totalGasLimitMax }); const estimate = calculateGasCost({ chainId, - gas: totalGasLimit, + gas: totalGasLimitEstimate, + maxFeePerGas, + maxPriorityFeePerGas, messenger, }); const max = calculateGasCost({ chainId, - gas: totalGasLimit, + gas: totalGasLimitMax, + maxFeePerGas, + maxPriorityFeePerGas, messenger, isMax: true, }); - return { estimate, max }; + const nativeBalance = getTokenBalance( + messenger, + from, + sourceChainId, + getNativeToken(sourceChainId), + ); + + if (new BigNumber(nativeBalance).isGreaterThanOrEqualTo(max.raw)) { + return { estimate, max }; + } + + if (relayDisabledGasStationChains.includes(sourceChainId)) { + log('Skipping gas station as disabled chain', { + sourceChainId, + disabledChainIds: relayDisabledGasStationChains, + }); + + return { estimate, max }; + } + + log('Checking gas fee tokens as insufficient native balance', { + nativeBalance, + max: max.raw, + }); + + const gasFeeTokens = await messenger.call( + 'TransactionController:getGasFeeTokens', + { + chainId: sourceChainId, + data, + from, + to, + value: toHex(value ?? '0'), + }, + ); + + log('Source gas fee tokens', { gasFeeTokens }); + + const gasFeeToken = gasFeeTokens.find( + (t) => t.tokenAddress.toLowerCase() === sourceTokenAddress.toLowerCase(), + ); + + if (!gasFeeToken) { + log('No matching gas fee token found', { + sourceTokenAddress, + gasFeeTokens, + }); + + return { estimate, max }; + } + + let finalAmount = gasFeeToken.amount; + + if (allParams.length > 1) { + const gasRate = new BigNumber(gasFeeToken.amount, 16).dividedBy( + gasFeeToken.gas, + 16, + ); + + const finalAmountValue = gasRate.multipliedBy(totalGasLimitEstimate); + + finalAmount = toHex(finalAmountValue.toFixed(0)); + + log('Estimated gas fee token amount for batch', { + finalAmount: finalAmountValue.toString(10), + gasRate: gasRate.toString(10), + totalGasLimitEstimate, + }); + } + + const finalGasFeeToken = { ...gasFeeToken, amount: finalAmount }; + + const gasFeeTokenCost = calculateGasFeeTokenCost({ + chainId: sourceChainId, + gasFeeToken: finalGasFeeToken, + messenger, + }); + + if (!gasFeeTokenCost) { + return { estimate, max }; + } + + log('Using gas fee token for source network', { + gasFeeTokenCost, + }); + + return { + isGasFeeToken: true, + estimate: gasFeeTokenCost, + max: gasFeeTokenCost, + }; } /** * Calculate the total gas limit for the source network transactions. * * @param params - Array of transaction parameters. + * @param messenger - Controller messenger. + * @param options - Options. + * @param options.isMax - Whether to calculate the maximum gas limit. * @returns - Total gas limit. */ function calculateSourceNetworkGasLimit( params: RelayQuote['steps'][0]['items'][0]['data'][], + messenger: TransactionPayControllerMessenger, + { isMax }: { isMax: boolean }, ): number { const allParamsHasGas = params.every((p) => p.gas !== undefined); @@ -423,11 +551,14 @@ function calculateSourceNetworkGasLimit( // In future, call `TransactionController:estimateGas` // or `TransactionController:estimateGasBatch` based on params length. - return params.reduce( - (total, p) => - total + new BigNumber(p.gas ?? RELAY_FALLBACK_GAS_LIMIT).toNumber(), - 0, - ); + const fallbackGas = getFeatureFlags(messenger).relayFallbackGas; + + return params.reduce((total, p) => { + const fallback = isMax ? fallbackGas.max : fallbackGas.estimate; + const gas = p.gas ?? fallback; + + return total + new BigNumber(gas).toNumber(); + }, 0); } /** @@ -437,11 +568,30 @@ function calculateSourceNetworkGasLimit( * @returns - Provider fee in USD. */ function calculateProviderFee(quote: RelayQuote) { - const relayerFee = new BigNumber(quote.fees.relayer.amountUsd); + return new BigNumber(quote.details.totalImpact.usd).abs(); +} - const valueLoss = new BigNumber(quote.details.currencyIn.amountUsd).minus( - quote.details.currencyOut.amountUsd, - ); +/** + * Build token transfer data. + * + * @param recipient - Recipient address. + * @param amountRaw - Amount in raw format. + * @returns Token transfer data. + */ +function buildTokenTransferData(recipient: Hex, amountRaw: string) { + return new Interface([ + 'function transfer(address to, uint256 amount)', + ]).encodeFunctionData('transfer', [recipient, amountRaw]); +} - return relayerFee.gt(valueLoss) ? relayerFee : valueLoss; +/** + * Get transfer recipient from token transfer data. + * + * @param data - Token transfer data. + * @returns Transfer recipient. + */ +function getTransferRecipient(data: Hex): Hex | undefined { + return new Interface(['function transfer(address to, uint256 amount)']) + .decodeFunctionData('transfer', data) + .to.toLowerCase(); } diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index a97d5792621..2c1966205c0 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -1,8 +1,10 @@ -import { ORIGIN_METAMASK, successfulFetch } from '@metamask/controller-utils'; import { - TransactionType, - type TransactionMeta, -} from '@metamask/transaction-controller'; + ORIGIN_METAMASK, + successfulFetch, + toHex, +} from '@metamask/controller-utils'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { cloneDeep } from 'lodash'; @@ -15,6 +17,8 @@ import type { TransactionPayControllerMessenger, TransactionPayQuote, } from '../../types'; +import type { FeatureFlags } from '../../utils/feature-flags'; +import { getFeatureFlags } from '../../utils/feature-flags'; import { collectTransactionIds, getTransaction, @@ -23,6 +27,7 @@ import { } from '../../utils/transaction'; jest.mock('../../utils/transaction'); +jest.mock('../../utils/feature-flags'); jest.mock('@metamask/controller-utils', () => ({ ...jest.requireActual('@metamask/controller-utils'), @@ -34,6 +39,8 @@ const TRANSACTION_HASH_MOCK = '0x1234'; const ENDPOINT_MOCK = '/test123'; const ORIGINAL_TRANSACTION_ID_MOCK = '456-789'; const FROM_MOCK = '0xabcde' as Hex; +const CHAIN_ID_MOCK = '0x1' as Hex; +const TOKEN_ADDRESS_MOCK = '0x123' as Hex; const TRANSACTION_META_MOCK = { id: '123-456', @@ -53,6 +60,7 @@ const ORIGINAL_QUOTE_MOCK = { }, }, }, + request: {}, steps: [ { kind: 'transaction', @@ -87,7 +95,15 @@ const STATUS_RESPONSE_MOCK = { const REQUEST_MOCK: PayStrategyExecuteRequest = { quotes: [ { + fees: { + sourceNetwork: {}, + }, original: ORIGINAL_QUOTE_MOCK, + request: { + from: FROM_MOCK, + sourceChainId: CHAIN_ID_MOCK, + sourceTokenAddress: TOKEN_ADDRESS_MOCK, + }, } as TransactionPayQuote, ], messenger: {} as TransactionPayControllerMessenger, @@ -102,6 +118,7 @@ describe('Relay Submit Utils', () => { const successfulFetchMock = jest.mocked(successfulFetch); const getTransactionMock = jest.mocked(getTransaction); const collectTransactionIdsMock = jest.mocked(collectTransactionIds); + const getFeatureFlagsMock = jest.mocked(getFeatureFlags); const { addTransactionMock, @@ -129,6 +146,12 @@ describe('Relay Submit Utils', () => { waitForTransactionConfirmedMock.mockResolvedValue(); getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); + getFeatureFlagsMock.mockReturnValue({ + relayFallbackGas: { + max: 123, + }, + } as FeatureFlags); + collectTransactionIdsMock.mockImplementation( (_chainId, _from, _messenger, fn) => { fn(TRANSACTION_META_MOCK.id); @@ -167,7 +190,104 @@ describe('Relay Submit Utils', () => { ); }); - it('adds batch transaction if multiple params', async () => { + it('adds transaction with gas fee token if isSourceGasFeeToken', async () => { + request.quotes[0].fees.isSourceGasFeeToken = true; + + await submitRelayQuotes(request); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + gasFeeToken: TOKEN_ADDRESS_MOCK, + }), + ); + }); + + it('adds transaction with authorization list if same chain and authorization list present', async () => { + request.quotes[0].original.details.currencyOut.currency.chainId = 1; + request.quotes[0].original.request = { + authorizationList: [ + { + address: '0xabc' as Hex, + chainId: 1, + nonce: 2, + r: '0xr' as Hex, + s: '0xs' as Hex, + yParity: 1, + }, + { + address: '0xdef' as Hex, + chainId: 1, + nonce: 3, + r: '0xr2' as Hex, + s: '0xs2' as Hex, + yParity: 0, + }, + ], + } as never; + + await submitRelayQuotes(request); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationList: [ + { + address: '0xabc', + chainId: '0x1', + }, + { + address: '0xdef', + chainId: '0x1', + }, + ], + }), + expect.anything(), + ); + }); + + it('does not add authorization list if different chains', async () => { + request.quotes[0].original.request = { + authorizationList: [ + { + address: '0xabc' as Hex, + chainId: 1, + nonce: 2, + r: '0xr' as Hex, + s: '0xs' as Hex, + yParity: 1, + }, + ], + } as never; + + await submitRelayQuotes(request); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).toHaveBeenCalledWith( + expect.not.objectContaining({ + authorizationList: expect.anything(), + }), + expect.anything(), + ); + }); + + it('does not add authorization list if same chain but no authorization list', async () => { + request.quotes[0].original.details.currencyOut.currency.chainId = 1; + request.quotes[0].original.request = {} as never; + + await submitRelayQuotes(request); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).toHaveBeenCalledWith( + expect.not.objectContaining({ + authorizationList: expect.anything(), + }), + expect.anything(), + ); + }); + + it('adds transaction batch if multiple params', async () => { request.quotes[0].original.steps[0].items.push({ ...request.quotes[0].original.steps[0].items[0], }); @@ -179,12 +299,15 @@ describe('Relay Submit Utils', () => { from: FROM_MOCK, networkClientId: NETWORK_CLIENT_ID_MOCK, origin: ORIGIN_METAMASK, + overwriteUpgrade: true, requireApproval: false, transactions: [ { params: { data: '0x1234', gas: '0x5208', + maxFeePerGas: '0x5d21dba00', + maxPriorityFeePerGas: '0x3b9aca00', to: '0xfedcb', value: '0x4d2', }, @@ -194,6 +317,8 @@ describe('Relay Submit Utils', () => { params: { data: '0x1234', gas: '0x5208', + maxFeePerGas: '0x5d21dba00', + maxPriorityFeePerGas: '0x3b9aca00', to: '0xfedcb', value: '0x4d2', }, @@ -202,6 +327,23 @@ describe('Relay Submit Utils', () => { }); }); + it('adds transaction batch with gas fee token if isSourceGasFeeToken', async () => { + request.quotes[0].original.steps[0].items.push({ + ...request.quotes[0].original.steps[0].items[0], + }); + + request.quotes[0].fees.isSourceGasFeeToken = true; + + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + gasFeeToken: TOKEN_ADDRESS_MOCK, + }), + ); + }); + it('adds transaction if params missing', async () => { request.quotes[0].original.steps[0].items[0].data.value = undefined as never; @@ -214,7 +356,7 @@ describe('Relay Submit Utils', () => { expect(addTransactionMock).toHaveBeenCalledTimes(1); expect(addTransactionMock).toHaveBeenCalledWith( expect.objectContaining({ - gas: '0xdbba0', + gas: toHex(123), value: '0x0', }), expect.anything(), diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 45030386d97..b396dee207f 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -3,25 +3,24 @@ import { successfulFetch, toHex, } from '@metamask/controller-utils'; -import { - TransactionType, - type TransactionParams, +import { TransactionType } from '@metamask/transaction-controller'; +import type { TransactionParams } from '@metamask/transaction-controller'; +import type { + AuthorizationList, + TransactionMeta, } from '@metamask/transaction-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; -import { - RELAY_FALLBACK_GAS_LIMIT, - RELAY_POLLING_INTERVAL, - RELAY_URL_BASE, -} from './constants'; +import { RELAY_POLLING_INTERVAL, RELAY_URL_BASE } from './constants'; import type { RelayQuote, RelayStatus } from './types'; import { projectLogger } from '../../logger'; import type { PayStrategyExecuteRequest, TransactionPayControllerMessenger, + TransactionPayQuote, } from '../../types'; +import { getFeatureFlags } from '../../utils/feature-flags'; import { collectTransactionIds, getTransaction, @@ -50,7 +49,7 @@ export async function submitRelayQuotes( for (const quote of quotes) { ({ transactionHash } = await executeSingleQuote( - quote.original, + quote, messenger, transaction, )); @@ -68,22 +67,12 @@ export async function submitRelayQuotes( * @returns An object containing the transaction hash if available. */ async function executeSingleQuote( - quote: RelayQuote, + quote: TransactionPayQuote, messenger: TransactionPayControllerMessenger, transaction: TransactionMeta, ) { log('Executing single quote', quote); - const { kind } = quote.steps[0]; - - if (kind !== 'transaction') { - throw new Error(`Unsupported step kind: ${kind as string}`); - } - - const transactionParams = quote.steps[0].items[0].data; - const chainId = toHex(transactionParams.chainId); - const from = transactionParams.from as Hex; - updateTransaction( { transactionId: transaction.id, @@ -95,9 +84,9 @@ async function executeSingleQuote( }, ); - await submitTransactions(quote, chainId, from, transaction.id, messenger); + await submitTransactions(quote, transaction.id, messenger); - const targetHash = await waitForRelayCompletion(quote); + const targetHash = await waitForRelayCompletion(quote.original); log('Relay request completed', targetHash); @@ -159,15 +148,19 @@ async function waitForRelayCompletion(quote: RelayQuote): Promise { * Normalize the parameters from a relay quote step to match TransactionParams. * * @param params - Parameters from a relay quote step. + * @param messenger - Controller messenger. * @returns Normalized transaction parameters. */ function normalizeParams( params: RelayQuote['steps'][0]['items'][0]['data'], + messenger: TransactionPayControllerMessenger, ): TransactionParams { + const featureFlags = getFeatureFlags(messenger); + return { data: params.data, from: params.from, - gas: toHex(params.gas ?? RELAY_FALLBACK_GAS_LIMIT), + gas: toHex(params.gas ?? featureFlags.relayFallbackGas.max), maxFeePerGas: toHex(params.maxFeePerGas), maxPriorityFeePerGas: toHex(params.maxPriorityFeePerGas), to: params.to, @@ -179,37 +172,42 @@ function normalizeParams( * Submit transactions for a relay quote. * * @param quote - Relay quote. - * @param chainId - ID of the chain. - * @param from - Address of the sender. * @param parentTransactionId - ID of the parent transaction. * @param messenger - Controller messenger. * @returns Hash of the last submitted transaction. */ async function submitTransactions( - quote: RelayQuote, - chainId: Hex, - from: Hex, + quote: TransactionPayQuote, parentTransactionId: string, messenger: TransactionPayControllerMessenger, ): Promise { - const params = quote.steps.flatMap((s) => s.items).map((i) => i.data); - const normalizedParams = params.map(normalizeParams); + const { steps } = quote.original; + const params = steps.flatMap((s) => s.items).map((i) => i.data); + const invalidKind = steps.find((s) => s.kind !== 'transaction')?.kind; + + if (invalidKind) { + throw new Error(`Unsupported step kind: ${invalidKind}`); + } + + const normalizedParams = params.map((p) => normalizeParams(p, messenger)); + const transactionIds: string[] = []; + const { from, sourceChainId, sourceTokenAddress } = quote.request; const networkClientId = messenger.call( 'NetworkController:findNetworkClientIdByChainId', - chainId, + sourceChainId, ); log('Adding transactions', { normalizedParams, - chainId, + sourceChainId, from, networkClientId, }); const { end } = collectTransactionIds( - chainId, + sourceChainId, from, messenger, (transactionId) => { @@ -234,11 +232,28 @@ async function submitTransactions( let result: { result: Promise } | undefined; + const gasFeeToken = quote.fees.isSourceGasFeeToken + ? sourceTokenAddress + : undefined; + + const isSameChain = + quote.original.details.currencyIn.currency.chainId === + quote.original.details.currencyOut.currency.chainId; + + const authorizationList: AuthorizationList | undefined = + isSameChain && quote.original.request.authorizationList?.length + ? quote.original.request.authorizationList.map((a) => ({ + address: a.address, + chainId: toHex(a.chainId), + })) + : undefined; + if (params.length === 1) { result = await messenger.call( 'TransactionController:addTransaction', - normalizedParams[0], + { ...normalizedParams[0], authorizationList }, { + gasFeeToken, networkClientId, origin: ORIGIN_METAMASK, requireApproval: false, @@ -247,13 +262,17 @@ async function submitTransactions( } else { await messenger.call('TransactionController:addTransactionBatch', { from, + gasFeeToken, networkClientId, origin: ORIGIN_METAMASK, + overwriteUpgrade: true, requireApproval: false, transactions: normalizedParams.map((p, i) => ({ params: { data: p.data as Hex, gas: p.gas as Hex, + maxFeePerGas: p.maxFeePerGas as Hex, + maxPriorityFeePerGas: p.maxPriorityFeePerGas as Hex, to: p.to as Hex, value: p.value as Hex, }, diff --git a/packages/transaction-pay-controller/src/strategy/relay/types.ts b/packages/transaction-pay-controller/src/strategy/relay/types.ts index bc3ed2560e8..cbf17d49296 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/types.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/types.ts @@ -1,5 +1,31 @@ import type { Hex } from '@metamask/utils'; +export type RelayQuoteRequest = { + amount: string; + authorizationList?: { + address: Hex; + chainId: number; + nonce: number; + r: Hex; + s: Hex; + yParity: number; + }[]; + destinationChainId: number; + destinationCurrency: Hex; + originChainId: number; + originCurrency: Hex; + recipient: Hex; + refundTo?: Hex; + slippageTolerance?: string; + tradeType: 'EXPECTED_OUTPUT' | 'EXACT_OUTPUT'; + txs?: { + to: Hex; + data: Hex; + value: Hex; + }[]; + user: Hex; +}; + export type RelayQuote = { details: { currencyIn: { @@ -21,12 +47,16 @@ export type RelayQuote = { minimumAmount: string; }; timeEstimate: number; + totalImpact: { + usd: string; + }; }; fees: { relayer: { amountUsd: string; }; }; + request: RelayQuoteRequest; steps: { items: { check: { diff --git a/packages/transaction-pay-controller/src/tests/messenger-mock.ts b/packages/transaction-pay-controller/src/tests/messenger-mock.ts index c3ecd2b7a17..0edbc02a32d 100644 --- a/packages/transaction-pay-controller/src/tests/messenger-mock.ts +++ b/packages/transaction-pay-controller/src/tests/messenger-mock.ts @@ -15,6 +15,7 @@ import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote import type { TransactionControllerAddTransactionAction, TransactionControllerAddTransactionBatchAction, + TransactionControllerGetGasFeeTokensAction, TransactionControllerGetStateAction, } from '@metamask/transaction-controller'; import type { TransactionControllerUpdateTransactionAction } from '@metamask/transaction-controller'; @@ -25,7 +26,7 @@ import type { TransactionPayControllerGetDelegationTransactionAction, TransactionPayControllerGetStrategyAction, } from '../types'; -import { type TransactionPayControllerGetStateAction } from '../types'; +import type { TransactionPayControllerGetStateAction } from '../types'; type AllActions = MessengerActions; type AllEvents = MessengerEvents; @@ -111,6 +112,10 @@ export function getMessengerMock({ TransactionPayControllerGetDelegationTransactionAction['handler'] > = jest.fn(); + const getGasFeeTokensMock: jest.MockedFn< + TransactionControllerGetGasFeeTokensAction['handler'] + > = jest.fn(); + const messenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, }); @@ -210,6 +215,11 @@ export function getMessengerMock({ 'TransactionPayController:getDelegationTransaction', getDelegationTransactionMock, ); + + messenger.registerActionHandler( + 'TransactionController:getGasFeeTokens', + getGasFeeTokensMock, + ); } const publish = messenger.publish.bind(messenger); @@ -225,6 +235,7 @@ export function getMessengerMock({ getCurrencyRateControllerStateMock, getDelegationTransactionMock, getGasFeeControllerStateMock, + getGasFeeTokensMock, getNetworkClientByIdMock, getRemoteFeatureFlagControllerStateMock, getStrategyMock, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 05c3fd1e1c0..fa13bada97b 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -21,12 +21,15 @@ import type { TransactionControllerAddTransactionBatchAction, TransactionControllerUnapprovedTransactionAddedEvent, } from '@metamask/transaction-controller'; -import type { TransactionControllerGetStateAction } from '@metamask/transaction-controller'; -import type { TransactionControllerStateChangeEvent } from '@metamask/transaction-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; -import type { TransactionControllerAddTransactionAction } from '@metamask/transaction-controller'; -import type { TransactionControllerUpdateTransactionAction } from '@metamask/transaction-controller'; -import type { BatchTransaction } from '@metamask/transaction-controller'; +import type { + BatchTransaction, + TransactionControllerAddTransactionAction, + TransactionControllerGetGasFeeTokensAction, + TransactionControllerGetStateAction, + TransactionControllerStateChangeEvent, + TransactionControllerUpdateTransactionAction, + TransactionMeta, +} from '@metamask/transaction-controller'; import type { Hex, Json } from '@metamask/utils'; import type { Draft } from 'immer'; @@ -47,6 +50,7 @@ export type AllowedActions = | TokensControllerGetStateAction | TransactionControllerAddTransactionAction | TransactionControllerAddTransactionBatchAction + | TransactionControllerGetGasFeeTokensAction | TransactionControllerGetStateAction | TransactionControllerUpdateTransactionAction; @@ -273,6 +277,9 @@ export type QuoteRequest = { /** Fees associated with a transaction pay quote. */ export type TransactionPayFees = { + /** Whether a gas fee token is used to pay source network fees. */ + isSourceGasFeeToken?: boolean; + /** Whether a gas fee token is used to pay target network fees. */ isTargetGasFeeToken?: boolean; diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts new file mode 100644 index 00000000000..c5c29e91770 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -0,0 +1,74 @@ +import { + DEFAULT_RELAY_FALLBACK_GAS_ESTIMATE, + DEFAULT_RELAY_FALLBACK_GAS_MAX, + DEFAULT_RELAY_QUOTE_URL, + DEFAULT_SLIPPAGE, + getFeatureFlags, +} from './feature-flags'; +import { getMessengerMock } from '../tests/messenger-mock'; + +const GAS_FALLBACK_ESTIMATE_MOCK = 123; +const GAS_FALLBACK_MAX_MOCK = 456; +const RELAY_QUOTE_URL_MOCK = 'https://test.com/test'; +const RELAY_GAS_STATION_DISABLED_CHAINS_MOCK = ['0x1', '0x2']; +const SLIPPAGE_MOCK = 0.01; + +describe('Feature Flags Utils', () => { + const { messenger, getRemoteFeatureFlagControllerStateMock } = + getMessengerMock(); + + beforeEach(() => { + jest.resetAllMocks(); + + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + cacheTimestamp: 0, + remoteFeatureFlags: {}, + }); + }); + + describe('getFeatureFlags', () => { + it('returns default feature flags when none are set', () => { + const featureFlags = getFeatureFlags(messenger); + + expect(featureFlags).toStrictEqual({ + relayDisabledGasStationChains: [], + relayFallbackGas: { + estimate: DEFAULT_RELAY_FALLBACK_GAS_ESTIMATE, + max: DEFAULT_RELAY_FALLBACK_GAS_MAX, + }, + relayQuoteUrl: DEFAULT_RELAY_QUOTE_URL, + slippage: DEFAULT_SLIPPAGE, + }); + }); + + it('returns feature flags', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + cacheTimestamp: 0, + remoteFeatureFlags: { + confirmations_pay: { + relayDisabledGasStationChains: + RELAY_GAS_STATION_DISABLED_CHAINS_MOCK, + relayFallbackGas: { + estimate: GAS_FALLBACK_ESTIMATE_MOCK, + max: GAS_FALLBACK_MAX_MOCK, + }, + relayQuoteUrl: RELAY_QUOTE_URL_MOCK, + slippage: SLIPPAGE_MOCK, + }, + }, + }); + + const featureFlags = getFeatureFlags(messenger); + + expect(featureFlags).toStrictEqual({ + relayDisabledGasStationChains: RELAY_GAS_STATION_DISABLED_CHAINS_MOCK, + relayFallbackGas: { + estimate: GAS_FALLBACK_ESTIMATE_MOCK, + max: GAS_FALLBACK_MAX_MOCK, + }, + relayQuoteUrl: RELAY_QUOTE_URL_MOCK, + slippage: SLIPPAGE_MOCK, + }); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts new file mode 100644 index 00000000000..c18ae48fe9f --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -0,0 +1,76 @@ +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import type { TransactionPayControllerMessenger } from '..'; +import { projectLogger } from '../logger'; +import { RELAY_URL_BASE } from '../strategy/relay/constants'; + +const log = createModuleLogger(projectLogger, 'feature-flags'); + +export const DEFAULT_RELAY_FALLBACK_GAS_ESTIMATE = 900000; +export const DEFAULT_RELAY_FALLBACK_GAS_MAX = 1500000; +export const DEFAULT_RELAY_QUOTE_URL = `${RELAY_URL_BASE}/quote`; +export const DEFAULT_SLIPPAGE = 0.005; + +type FeatureFlagsRaw = { + relayDisabledGasStationChains?: Hex[]; + relayFallbackGas?: { + estimate?: number; + max?: number; + }; + relayQuoteUrl?: string; + slippage?: number; +}; + +export type FeatureFlags = { + relayDisabledGasStationChains: Hex[]; + relayFallbackGas: { + estimate: number; + max: number; + }; + relayQuoteUrl: string; + slippage: number; +}; + +/** + * Get feature flags related to the controller. + * + * @param messenger - Controller messenger. + * @returns Feature flags. + */ +export function getFeatureFlags( + messenger: TransactionPayControllerMessenger, +): FeatureFlags { + const state = messenger.call('RemoteFeatureFlagController:getState'); + + const featureFlags: FeatureFlagsRaw = + (state.remoteFeatureFlags.confirmations_pay as FeatureFlagsRaw) ?? {}; + + const estimate = + featureFlags.relayFallbackGas?.estimate ?? + DEFAULT_RELAY_FALLBACK_GAS_ESTIMATE; + + const max = + featureFlags.relayFallbackGas?.max ?? DEFAULT_RELAY_FALLBACK_GAS_MAX; + + const relayQuoteUrl = featureFlags.relayQuoteUrl ?? DEFAULT_RELAY_QUOTE_URL; + + const relayDisabledGasStationChains = + featureFlags.relayDisabledGasStationChains ?? []; + + const slippage = featureFlags.slippage ?? DEFAULT_SLIPPAGE; + + const result = { + relayDisabledGasStationChains, + relayFallbackGas: { + estimate, + max, + }, + relayQuoteUrl, + slippage, + }; + + log('Feature flags:', { raw: featureFlags, result }); + + return result; +} diff --git a/packages/transaction-pay-controller/src/utils/gas.test.ts b/packages/transaction-pay-controller/src/utils/gas.test.ts index afcba93be99..9f505008e57 100644 --- a/packages/transaction-pay-controller/src/utils/gas.test.ts +++ b/packages/transaction-pay-controller/src/utils/gas.test.ts @@ -2,8 +2,12 @@ import { toHex } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; import { clone, cloneDeep } from 'lodash'; -import { calculateGasCost, calculateTransactionGasCost } from './gas'; -import { getTokenBalance, getTokenFiatRate, getTokenInfo } from './token'; +import { + calculateGasCost, + calculateGasFeeTokenCost, + calculateTransactionGasCost, +} from './gas'; +import { getTokenBalance, getTokenFiatRate } from './token'; import type { GasFeeEstimates } from '../../../gas-fee-controller/src'; import type { GasFeeToken, @@ -23,11 +27,12 @@ const TOKEN_ADDRESS_MOCK = '0x789' as Hex; const GAS_FEE_TOKEN_MOCK = { amount: toHex(1230000), + decimals: 6, tokenAddress: TOKEN_ADDRESS_MOCK, } as GasFeeToken; const TRANSACTION_META_MOCK = { - chainId: CHAIN_ID_MOCK as Hex, + chainId: CHAIN_ID_MOCK, gasUsed: GAS_USED_MOCK, txParams: { gas: GAS_MOCK, @@ -51,7 +56,6 @@ const GAS_FEE_CONTROLLER_STATE_MOCK = { describe('Gas Utils', () => { const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); - const getTokenInfoMock = jest.mocked(getTokenInfo); const getTokenBalanceMock = jest.mocked(getTokenBalance); const { messenger, getGasFeeControllerStateMock } = getMessengerMock(); @@ -65,11 +69,6 @@ describe('Gas Utils', () => { usdRate: '4000', fiatRate: '2000', }); - - getTokenInfoMock.mockReturnValue({ - decimals: 6, - symbol: 'TST', - }); }); describe('calculateGasCost', () => { @@ -322,24 +321,6 @@ describe('Gas Utils', () => { }); }); - it('does not return gas fee token if token info is unavailable', () => { - const transactionMeta = clone(TRANSACTION_META_MOCK); - - transactionMeta.gasFeeTokens = [GAS_FEE_TOKEN_MOCK]; - transactionMeta.selectedGasFeeToken = TOKEN_ADDRESS_MOCK; - - getTokenInfoMock.mockReturnValue(undefined); - - const result = calculateTransactionGasCost(transactionMeta, messenger); - - expect(result).toStrictEqual({ - fiat: '0.273', - human: '0.0001365', - raw: '136500000000000', - usd: '0.546', - }); - }); - it('does not return gas fee token if fiat rate is unavailable', () => { const transactionMeta = clone(TRANSACTION_META_MOCK); @@ -384,4 +365,34 @@ describe('Gas Utils', () => { }); }); }); + + describe('calculateGasFeeTokenCost', () => { + it('returns gas fee token cost', () => { + const result = calculateGasFeeTokenCost({ + chainId: CHAIN_ID_MOCK, + gasFeeToken: GAS_FEE_TOKEN_MOCK, + messenger, + }); + + expect(result).toStrictEqual({ + isGasFeeToken: true, + fiat: '2460', + human: '1.23', + raw: '1230000', + usd: '4920', + }); + }); + + it('returns undefined if fiat rate is unavailable', () => { + getTokenFiatRateMock.mockReturnValue(undefined); + + const result = calculateGasFeeTokenCost({ + chainId: CHAIN_ID_MOCK, + gasFeeToken: GAS_FEE_TOKEN_MOCK, + messenger, + }); + + expect(result).toBeUndefined(); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/gas.ts b/packages/transaction-pay-controller/src/utils/gas.ts index 04a350de421..8d166de8fd9 100644 --- a/packages/transaction-pay-controller/src/utils/gas.ts +++ b/packages/transaction-pay-controller/src/utils/gas.ts @@ -1,15 +1,13 @@ import { toHex } from '@metamask/controller-utils'; import type { GasFeeEstimates } from '@metamask/gas-fee-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { + GasFeeToken, + TransactionMeta, +} from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { - getNativeToken, - getTokenBalance, - getTokenFiatRate, - getTokenInfo, -} from './token'; +import { getNativeToken, getTokenBalance, getTokenFiatRate } from './token'; import type { TransactionPayControllerMessenger } from '..'; import { createModuleLogger, projectLogger } from '../logger'; import type { Amount } from '../types'; @@ -67,7 +65,7 @@ export function calculateTransactionGasCost( const hasBalance = new BigNumber(nativeBalance).gte(max.raw); - const gasFeeTokenCost = calculateGasFeeTokenCost({ + const gasFeeTokenCost = calculateTransactionGasFeeTokenCost({ hasBalance, messenger, transaction, @@ -155,6 +153,51 @@ export function calculateGasCost(request: { }; } +/** + * Calculate the cost of a gas fee token on a transaction. + * + * @param request - Request parameters. + * @param request.chainId - Chain ID. + * @param request.gasFeeToken - Gas fee token to calculate cost for. + * @param request.messenger - Controller messenger. + * @returns Cost of the gas fee token. + */ +export function calculateGasFeeTokenCost({ + chainId, + gasFeeToken, + messenger, +}: { + chainId: Hex; + gasFeeToken: GasFeeToken; + messenger: TransactionPayControllerMessenger; +}): (Amount & { isGasFeeToken?: boolean }) | undefined { + const { amount, decimals, tokenAddress } = gasFeeToken; + + const tokenFiatRate = getTokenFiatRate(messenger, tokenAddress, chainId); + + if (!tokenFiatRate) { + log('Cannot get gas fee token info'); + return undefined; + } + + const rawValue = new BigNumber(amount); + const raw = rawValue.toString(10); + + const humanValue = rawValue.shiftedBy(-decimals); + const human = humanValue.toString(10); + + const fiat = humanValue.multipliedBy(tokenFiatRate.fiatRate).toString(10); + const usd = humanValue.multipliedBy(tokenFiatRate.usdRate).toString(10); + + return { + isGasFeeToken: true, + fiat, + human, + raw, + usd, + }; +} + /** * Get gas fee estimates for a given chain. * @@ -197,7 +240,7 @@ function getGasFee(chainId: Hex, messenger: TransactionPayControllerMessenger) { * @param request.transaction - Transaction to calculate gas fee token cost for. * @returns Cost of the gas fee token. */ -function calculateGasFeeTokenCost({ +function calculateTransactionGasFeeTokenCost({ hasBalance, messenger, transaction, @@ -236,33 +279,9 @@ function calculateGasFeeTokenCost({ return undefined; } - const tokenInfo = getTokenInfo(messenger, selectedGasFeeToken, chainId); - - const tokenFiatRate = getTokenFiatRate( - messenger, - selectedGasFeeToken, + return calculateGasFeeTokenCost({ chainId, - ); - - if (!tokenFiatRate || !tokenInfo) { - log('Cannot get gas fee token info'); - return undefined; - } - - const rawValue = new BigNumber(gasFeeToken.amount); - const raw = rawValue.toString(10); - - const humanValue = rawValue.shiftedBy(-tokenInfo.decimals); - const human = humanValue.toString(10); - - const fiat = humanValue.multipliedBy(tokenFiatRate.fiatRate).toString(10); - const usd = humanValue.multipliedBy(tokenFiatRate.usdRate).toString(10); - - return { - isGasFeeToken: true, - fiat, - human, - raw, - usd, - }; + gasFeeToken, + messenger, + }); } diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index 9de6050952a..25ab8993e47 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -1,7 +1,5 @@ -import { - TransactionStatus, - type TransactionMeta, -} from '@metamask/transaction-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { BatchTransaction } from '@metamask/transaction-controller'; import type { Hex, Json } from '@metamask/utils'; import { cloneDeep } from 'lodash'; diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index f396ba94b6d..e27f9208c05 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -1,7 +1,5 @@ -import { - TransactionStatus, - type BatchTransaction, -} from '@metamask/transaction-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; +import type { BatchTransaction } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex, Json } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; diff --git a/packages/transaction-pay-controller/src/utils/required-tokens.ts b/packages/transaction-pay-controller/src/utils/required-tokens.ts index 8f70fe74f02..aba7bcfb14f 100644 --- a/packages/transaction-pay-controller/src/utils/required-tokens.ts +++ b/packages/transaction-pay-controller/src/utils/required-tokens.ts @@ -2,7 +2,8 @@ import { Interface } from '@ethersproject/abi'; import { toHex } from '@metamask/controller-utils'; import { abiERC20 } from '@metamask/metamask-eth-abis'; import type { TransactionMeta } from '@metamask/transaction-controller'; -import { add0x, type Hex } from '@metamask/utils'; +import { add0x } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts index 184e51cb2d1..f8af32058c3 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts @@ -1,7 +1,8 @@ import { updateSourceAmounts } from './source-amounts'; import { getTokenFiatRate } from './token'; import { getTransaction } from './transaction'; -import { TransactionPayStrategy, type TransactionPaymentToken } from '..'; +import { TransactionPayStrategy } from '..'; +import type { TransactionPaymentToken } from '..'; import { ARBITRUM_USDC_ADDRESS, CHAIN_ID_ARBITRUM } from '../constants'; import { getMessengerMock } from '../tests/messenger-mock'; import type { TransactionData, TransactionPayRequiredToken } from '../types'; diff --git a/packages/transaction-pay-controller/src/utils/token.test.ts b/packages/transaction-pay-controller/src/utils/token.test.ts index ddc09cbecb3..ffdaa5fc353 100644 --- a/packages/transaction-pay-controller/src/utils/token.test.ts +++ b/packages/transaction-pay-controller/src/utils/token.test.ts @@ -265,7 +265,7 @@ describe('Token Utils', () => { const result = getTokenFiatRate( messenger, - TOKEN_ADDRESS_MOCK as Hex, + TOKEN_ADDRESS_MOCK, CHAIN_ID_MOCK, ); @@ -281,7 +281,7 @@ describe('Token Utils', () => { const result = getTokenFiatRate( messenger, - TOKEN_ADDRESS_MOCK as Hex, + TOKEN_ADDRESS_MOCK, CHAIN_ID_MOCK, ); @@ -303,7 +303,7 @@ describe('Token Utils', () => { const result = getTokenFiatRate( messenger, - TOKEN_ADDRESS_MOCK as Hex, + TOKEN_ADDRESS_MOCK, CHAIN_ID_MOCK, ); @@ -333,7 +333,7 @@ describe('Token Utils', () => { const result = getTokenFiatRate( messenger, - TOKEN_ADDRESS_MOCK as Hex, + TOKEN_ADDRESS_MOCK, CHAIN_ID_MOCK, ); diff --git a/packages/transaction-pay-controller/src/utils/totals.test.ts b/packages/transaction-pay-controller/src/utils/totals.test.ts index 93111ab94b8..98c5891acde 100644 --- a/packages/transaction-pay-controller/src/utils/totals.test.ts +++ b/packages/transaction-pay-controller/src/utils/totals.test.ts @@ -2,10 +2,8 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import { calculateTransactionGasCost } from './gas'; import { calculateTotals } from './totals'; -import { - TransactionPayStrategy, - type TransactionPayControllerMessenger, -} from '..'; +import { TransactionPayStrategy } from '..'; +import type { TransactionPayControllerMessenger } from '..'; import type { QuoteRequest, TransactionPayQuote, @@ -74,6 +72,7 @@ const QUOTE_2_MOCK: TransactionPayQuote = { }, estimatedDuration: 234, fees: { + isSourceGasFeeToken: true, provider: { fiat: '7.77', usd: '8.88', @@ -232,6 +231,7 @@ describe('Totals Utils', () => { expect(result.sourceAmount.fiat).toBe('20.9'); expect(result.sourceAmount.usd).toBe('23.02'); + expect(result.fees.isSourceGasFeeToken).toBe(true); }); }); }); diff --git a/packages/transaction-pay-controller/src/utils/totals.ts b/packages/transaction-pay-controller/src/utils/totals.ts index 021f67113f1..5fee22c2fc4 100644 --- a/packages/transaction-pay-controller/src/utils/totals.ts +++ b/packages/transaction-pay-controller/src/utils/totals.ts @@ -75,6 +75,10 @@ export function calculateTotals({ sumProperty(quotes, (quote) => quote.estimatedDuration), ); + const isSourceGasFeeToken = quotes.some( + (quote) => quote.fees.isSourceGasFeeToken, + ); + const isTargetGasFeeToken = targetNetworkFee.isGasFeeToken || quotes.some((quote) => quote.fees.isTargetGasFeeToken); @@ -82,6 +86,7 @@ export function calculateTotals({ return { estimatedDuration, fees: { + isSourceGasFeeToken, isTargetGasFeeToken, provider: providerFee, sourceNetwork: { diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts index 156eebd6c9e..bd6f7ddb6ee 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -1,8 +1,8 @@ import { TransactionStatus, - type TransactionMeta, TransactionType, } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { TransactionControllerState } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { noop } from 'lodash'; diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index df21fc1fb0c..6ccf4609fb4 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -1,7 +1,5 @@ -import { - TransactionStatus, - type TransactionMeta, -} from '@metamask/transaction-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { cloneDeep } from 'lodash'; diff --git a/packages/user-operation-controller/CHANGELOG.md b/packages/user-operation-controller/CHANGELOG.md index 5c103a0cddc..799529e20c1 100644 --- a/packages/user-operation-controller/CHANGELOG.md +++ b/packages/user-operation-controller/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7220](https://github.com/MetaMask/core/pull/7220), [#7236](https://github.com/MetaMask/core/pull/7236), [#7257](https://github.com/MetaMask/core/pull/7257), [#7258](https://github.com/MetaMask/core/pull/7258), [#7289](https://github.com/MetaMask/core/pull/7289), [#7325](https://github.com/MetaMask/core/pull/7325)) + - The dependencies moved are: + - `@metamask/approval-controller` (^8.0.0) + - `@metamask/gas-fee-controller` (^26.0.0) + - `@metamask/keyring-controller` (^25.0.0) + - `@metamask/network-controller` (^27.0.0) + - `@metamask/transaction-controller` (^62.5.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + ## [41.0.0] ### Changed diff --git a/packages/user-operation-controller/package.json b/packages/user-operation-controller/package.json index 82bc795e4c1..8b942573d09 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-controller/package.json @@ -49,13 +49,18 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/approval-controller": "^8.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.16.0", "@metamask/eth-query": "^4.0.0", + "@metamask/gas-fee-controller": "^26.0.0", + "@metamask/keyring-controller": "^25.0.0", "@metamask/messenger": "^0.3.0", + "@metamask/network-controller": "^27.0.0", "@metamask/polling-controller": "^16.0.0", "@metamask/rpc-errors": "^7.0.2", "@metamask/superstruct": "^3.1.0", + "@metamask/transaction-controller": "^62.5.0", "@metamask/utils": "^11.8.1", "bn.js": "^5.2.1", "immer": "^9.0.6", @@ -63,13 +68,8 @@ "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/approval-controller": "^8.0.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-block-tracker": "^15.0.0", - "@metamask/gas-fee-controller": "^26.0.0", - "@metamask/keyring-controller": "^25.0.0", - "@metamask/network-controller": "^26.0.0", - "@metamask/transaction-controller": "^62.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -80,12 +80,7 @@ "typescript": "~5.3.3" }, "peerDependencies": { - "@metamask/approval-controller": "^8.0.0", - "@metamask/eth-block-tracker": ">=9", - "@metamask/gas-fee-controller": "^26.0.0", - "@metamask/keyring-controller": "^25.0.0", - "@metamask/network-controller": "^26.0.0", - "@metamask/transaction-controller": "^62.0.0" + "@metamask/eth-block-tracker": ">=9" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/user-operation-controller/src/UserOperationController.test.ts b/packages/user-operation-controller/src/UserOperationController.test.ts index 6f3c0e9e900..e176190e91d 100644 --- a/packages/user-operation-controller/src/UserOperationController.test.ts +++ b/packages/user-operation-controller/src/UserOperationController.test.ts @@ -4,21 +4,21 @@ import { errorCodes } from '@metamask/rpc-errors'; import { determineTransactionType, TransactionType, - type TransactionParams, } from '@metamask/transaction-controller'; +import type { TransactionParams } from '@metamask/transaction-controller'; import { EventEmitter } from 'stream'; import { ADDRESS_ZERO, EMPTY_BYTES, VALUE_ZERO } from './constants'; import * as BundlerHelper from './helpers/Bundler'; import * as PendingUserOperationTrackerHelper from './helpers/PendingUserOperationTracker'; import { SnapSmartContractAccount } from './helpers/SnapSmartContractAccount'; +import { UserOperationStatus } from './types'; import type { UserOperationMetadata } from './types'; -import { - UserOperationStatus, - type PrepareUserOperationResponse, - type SignUserOperationResponse, - type SmartContractAccount, - type UpdateUserOperationResponse, +import type { + PrepareUserOperationResponse, + SignUserOperationResponse, + SmartContractAccount, + UpdateUserOperationResponse, } from './types'; import type { AddUserOperationOptions, diff --git a/packages/user-operation-controller/src/UserOperationController.ts b/packages/user-operation-controller/src/UserOperationController.ts index af6c00fbd09..94701a0a984 100644 --- a/packages/user-operation-controller/src/UserOperationController.ts +++ b/packages/user-operation-controller/src/UserOperationController.ts @@ -3,10 +3,10 @@ import type { AddApprovalRequest, AddResult, } from '@metamask/approval-controller'; -import { - BaseController, - type ControllerGetStateAction, - type ControllerStateChangeEvent, +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; import { ApprovalType } from '@metamask/controller-utils'; import EthQuery from '@metamask/eth-query'; @@ -22,11 +22,11 @@ import type { Provider, } from '@metamask/network-controller'; import { errorCodes } from '@metamask/rpc-errors'; -import { - determineTransactionType, - type TransactionMeta, - type TransactionParams, - type TransactionType, +import { determineTransactionType } from '@metamask/transaction-controller'; +import type { + TransactionMeta, + TransactionParams, + TransactionType, } from '@metamask/transaction-controller'; import { add0x } from '@metamask/utils'; // This package purposefully relies on Node's EventEmitter module. diff --git a/packages/user-operation-controller/src/helpers/PendingUserOperationTracker.ts b/packages/user-operation-controller/src/helpers/PendingUserOperationTracker.ts index 379c616bd59..3141c2dd76d 100644 --- a/packages/user-operation-controller/src/helpers/PendingUserOperationTracker.ts +++ b/packages/user-operation-controller/src/helpers/PendingUserOperationTracker.ts @@ -6,7 +6,8 @@ import type { Provider, } from '@metamask/network-controller'; import { BlockTrackerPollingControllerOnly } from '@metamask/polling-controller'; -import { createModuleLogger, type Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; // This package purposefully relies on Node's EventEmitter module. // eslint-disable-next-line import-x/no-nodejs-modules import EventEmitter from 'events'; diff --git a/packages/user-operation-controller/src/helpers/SnapSmartContractAccount.test.ts b/packages/user-operation-controller/src/helpers/SnapSmartContractAccount.test.ts index e958ed1e33b..916e98959b0 100644 --- a/packages/user-operation-controller/src/helpers/SnapSmartContractAccount.test.ts +++ b/packages/user-operation-controller/src/helpers/SnapSmartContractAccount.test.ts @@ -9,7 +9,7 @@ import type { UpdateUserOperationRequest, UpdateUserOperationResponse, } from '../types'; -import { type PrepareUserOperationRequest } from '../types'; +import type { PrepareUserOperationRequest } from '../types'; import type { UserOperationControllerMessenger } from '../UserOperationController'; import { toEip155ChainId } from '../utils/chain-id'; diff --git a/packages/user-operation-controller/src/utils/gas-fees.test.ts b/packages/user-operation-controller/src/utils/gas-fees.test.ts index 25cb1b1a0d2..794c98870e7 100644 --- a/packages/user-operation-controller/src/utils/gas-fees.test.ts +++ b/packages/user-operation-controller/src/utils/gas-fees.test.ts @@ -1,10 +1,8 @@ import { ORIGIN_METAMASK, query } from '@metamask/controller-utils'; import type { GasFeeState } from '@metamask/gas-fee-controller'; import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; -import { - UserFeeLevel, - type TransactionParams, -} from '@metamask/transaction-controller'; +import { UserFeeLevel } from '@metamask/transaction-controller'; +import type { TransactionParams } from '@metamask/transaction-controller'; import { cloneDeep } from 'lodash'; import type { UpdateGasFeesRequest } from './gas-fees'; diff --git a/packages/user-operation-controller/src/utils/gas-fees.ts b/packages/user-operation-controller/src/utils/gas-fees.ts index a86c980aee8..45ebaaaa274 100644 --- a/packages/user-operation-controller/src/utils/gas-fees.ts +++ b/packages/user-operation-controller/src/utils/gas-fees.ts @@ -5,10 +5,8 @@ import { toHex, } from '@metamask/controller-utils'; import EthQuery from '@metamask/eth-query'; -import { - GAS_ESTIMATE_TYPES, - type GasFeeState, -} from '@metamask/gas-fee-controller'; +import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; +import type { GasFeeState } from '@metamask/gas-fee-controller'; import type { Provider } from '@metamask/network-controller'; import type { TransactionParams } from '@metamask/transaction-controller'; import { UserFeeLevel } from '@metamask/transaction-controller'; @@ -277,7 +275,7 @@ async function getSuggestedGasFees( return {}; } - const maxFeePerGas = add0x(gasPriceDecimal.toString(16)) as Hex; + const maxFeePerGas = add0x(gasPriceDecimal.toString(16)); log('Using gasPrice from network as fallback', maxFeePerGas); diff --git a/packages/user-operation-controller/src/utils/transaction.test.ts b/packages/user-operation-controller/src/utils/transaction.test.ts index 50b7e6b146d..24ca1e9c3aa 100644 --- a/packages/user-operation-controller/src/utils/transaction.test.ts +++ b/packages/user-operation-controller/src/utils/transaction.test.ts @@ -6,8 +6,9 @@ import { UserFeeLevel, } from '../../../transaction-controller/src'; import { EMPTY_BYTES, VALUE_ZERO } from '../constants'; +import { UserOperationStatus } from '../types'; import type { UserOperation } from '../types'; -import { UserOperationStatus, type UserOperationMetadata } from '../types'; +import type { UserOperationMetadata } from '../types'; const USER_OPERATION_METADATA_MOCK: UserOperationMetadata = { id: 'testUserOperationId', diff --git a/teams.json b/teams.json index 674a5f9f182..1fce381c68b 100644 --- a/teams.json +++ b/teams.json @@ -26,7 +26,7 @@ "metamask/bridge-status-controller": "team-swaps-and-bridge", "metamask/app-metadata-controller": "team-mobile-platform", "metamask/token-search-discovery-controller": "team-portfolio", - "metamask/delegation-controller": "team-predict", + "metamask/delegation-controller": "team-delegation", "metamask/chain-agnostic-permission": "team-wallet-integrations", "metamask/eip1193-permission-middleware": "team-wallet-integrations", "metamask/multichain-api-middleware": "team-wallet-integrations", @@ -42,6 +42,7 @@ "metamask/polling-controller": "team-core-platform", "metamask/preferences-controller": "team-core-platform", "metamask/rate-limit-controller": "team-core-platform", + "metamask/profile-metrics-controller": "team-core-platform", "metamask/seedless-onboarding-controller": "team-web3auth", "metamask/shield-controller": "team-web3auth", "metamask/subscription-controller": "team-web3auth", @@ -60,5 +61,6 @@ "metamask/permission-controller": "team-wallet-integrations,team-core-platform", "metamask/permission-log-controller": "team-wallet-integrations,team-core-platform", "metamask/analytics-controller": "team-extension-platform,team-mobile-platform", - "metamask/remote-feature-flag-controller": "team-extension-platform,team-mobile-platform" + "metamask/remote-feature-flag-controller": "team-extension-platform,team-mobile-platform", + "metamask/storage-service": "team-extension-platform,team-mobile-platform" } diff --git a/tests/fake-provider.ts b/tests/fake-provider.ts index f46b314b03a..64116d4cbad 100644 --- a/tests/fake-provider.ts +++ b/tests/fake-provider.ts @@ -1,9 +1,9 @@ import { InternalProvider } from '@metamask/eth-json-rpc-provider'; -import { - JsonRpcEngineV2, - type JsonRpcMiddleware, - type MiddlewareContext, - type ResultConstraint, +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import type { + JsonRpcMiddleware, + MiddlewareContext, + ResultConstraint, } from '@metamask/json-rpc-engine/v2'; import type { Provider } from '@metamask/network-controller'; import type { @@ -220,9 +220,7 @@ export class FakeProvider throw new Error(message); } else { - // We are already checking that this stub exists above. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const stub = this.#stubs[index]!; + const stub = this.#stubs[index]; if (stub.discardAfterMatching !== false) { this.#stubs.splice(index, 1); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000000..a1fe2b64848 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,14 @@ +{ + /** + * This configuration is extended by all other TypeScript configurations. + */ + "compilerOptions": { + "composite": true, + "esModuleInterop": true, + "lib": ["ES2020", "DOM"], + "module": "Node16", + "moduleResolution": "Node16", + "strict": true, + "target": "ES2020" + } +} diff --git a/tsconfig.build.json b/tsconfig.build.json index d5222c5ef47..10a7c244911 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,11 @@ { + /** + * This configuration is used by the `build` script in `package.json`. + * + * This is just used as a way to request builds for all packages. `tsconfig.base.json` is not + * extended here because the `tsconfig.build.json` file referenced for each package already + * (indirectly) extends it. + */ "references": [ { "path": "./packages/account-tree-controller/tsconfig.build.json" }, { "path": "./packages/accounts-controller/tsconfig.build.json" }, @@ -55,6 +62,7 @@ { "path": "./packages/phishing-controller/tsconfig.build.json" }, { "path": "./packages/polling-controller/tsconfig.build.json" }, { "path": "./packages/preferences-controller/tsconfig.build.json" }, + { "path": "./packages/profile-metrics-controller/tsconfig.build.json" }, { "path": "./packages/profile-sync-controller/tsconfig.build.json" }, { "path": "./packages/rate-limit-controller/tsconfig.build.json" }, { "path": "./packages/remote-feature-flag-controller/tsconfig.build.json" }, @@ -63,6 +71,7 @@ { "path": "./packages/selected-network-controller/tsconfig.build.json" }, { "path": "./packages/shield-controller/tsconfig.build.json" }, { "path": "./packages/signature-controller/tsconfig.build.json" }, + { "path": "./packages/storage-service/tsconfig.build.json" }, { "path": "./packages/subscription-controller/tsconfig.build.json" }, { "path": "./packages/token-search-discovery-controller/tsconfig.build.json" diff --git a/tsconfig.json b/tsconfig.json index 18e11c67c43..34a9641f246 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,10 @@ { + /** + * This configuration is used by the `lint` script in `package.json`, and by editors such as + * VSCode for TypeScript-related features. + */ + "extends": "./tsconfig.base.json", "compilerOptions": { - "esModuleInterop": true, - "module": "Node16", - "moduleResolution": "Node16", "noEmit": true }, "references": [ @@ -52,6 +54,7 @@ { "path": "./packages/phishing-controller" }, { "path": "./packages/polling-controller" }, { "path": "./packages/preferences-controller" }, + { "path": "./packages/profile-metrics-controller" }, { "path": "./packages/profile-sync-controller" }, { "path": "./packages/rate-limit-controller" }, { "path": "./packages/remote-feature-flag-controller" }, diff --git a/tsconfig.packages.build.json b/tsconfig.packages.build.json index 10b7ba98ecd..e971538b76e 100644 --- a/tsconfig.packages.build.json +++ b/tsconfig.packages.build.json @@ -1,4 +1,7 @@ { + /** + * This configuration is extended by the `tsconfig.build.json` configuration in each package. + */ "extends": "./tsconfig.packages.json", "compilerOptions": { "declaration": true, diff --git a/tsconfig.packages.json b/tsconfig.packages.json index b02edfcb6b4..fbde19a5981 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -1,10 +1,9 @@ { + /** + * This configuration is extended by the `tsconfig.json` configuration in each package. + */ + "extends": "./tsconfig.base.json", "compilerOptions": { - "composite": true, - "esModuleInterop": true, - "lib": ["ES2020", "DOM"], - "module": "Node16", - "moduleResolution": "Node16", /** * Here we ensure that TypeScript resolves `@metamask/*` imports to the * uncompiled source code for packages that live in this repo. @@ -15,8 +14,6 @@ "paths": { "@metamask/json-rpc-engine/v2": ["../json-rpc-engine/src/v2/index.ts"], "@metamask/*": ["../*/src"] - }, - "strict": true, - "target": "ES2020" + } } } diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json index be29b7388f4..b4adc2c51e5 100644 --- a/tsconfig.scripts.json +++ b/tsconfig.scripts.json @@ -1,17 +1,22 @@ { + /** + * This configuration is intended for the `scripts/` directory, both for linting and for editor + * TypeScript-related features. + * + * It's currently not actually used for that purpose, but it will be in a future PR. + * + * This is also extended by the `tsconfig.json` file in `scripts/create-package/`, which _is_ + * actively used to support TypeScript-related editor features in that directory. + */ + "extends": "./tsconfig.base.json", "compilerOptions": { "baseUrl": "./", - "esModuleInterop": true, "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, "lib": ["ES2020"], - "module": "Node16", - "moduleResolution": "Node16", "noEmit": true, "noErrorTruncation": true, - "noUncheckedIndexedAccess": true, - "strict": true, - "target": "es2020" + "noUncheckedIndexedAccess": true }, "include": ["./scripts/**/*.ts"], "exclude": ["**/node_modules"] diff --git a/yarn.config.cjs b/yarn.config.cjs index 374fa6e7306..6b450dcfd5e 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -228,6 +228,14 @@ module.exports = defineConfig({ dependenciesByIdentAndType, ); + // Disallow workspace packages from listing other workspace packages via + // peer dependencies. + expectDependenciesForControllersAndServices( + Yarn, + workspace, + dependenciesByIdentAndType, + ); + // The root workspace (and only the root workspace) must specify the Yarn // version required for development. if (isChildWorkspace) { @@ -633,7 +641,7 @@ function expectUpToDateWorkspaceDependenciesAndDevDependencies( const prodDependency = dependencyInstancesByType.get('dependencies'); const peerDependency = dependencyInstancesByType.get('peerDependencies'); - if (devDependency || (prodDependency && !peerDependency)) { + if ((devDependency || prodDependency) && !peerDependency) { const dependency = devDependency ?? prodDependency; const ignoredRanges = ALLOWED_INCONSISTENT_DEPENDENCIES[dependencyIdent]; @@ -728,10 +736,8 @@ function expectDependenciesNotInBothProdAndDevOrPeer( } /** - * Expect that if the workspace package lists another package in its - * `peerDependencies`, the package is also listed in the workspace's - * `devDependencies`. If the other package is a workspace package, also expect - * that the dev dependency matches the current version of the package. + * Expect that if a non-workspace package lists another package in its + * `peerDependencies`, the package is also listed in `devDependencies`. * * @param {Yarn} Yarn - The Yarn "global". * @param {Workspace} workspace - The workspace to check. @@ -747,20 +753,80 @@ function expectPeerDependenciesAlsoListedAsDevDependencies( dependencyIdent, dependencyInstancesByType, ] of dependenciesByIdentAndType.entries()) { - if (!dependencyInstancesByType.has('peerDependencies')) { + const peerDependency = dependencyInstancesByType.get('peerDependencies'); + + if (!peerDependency) { continue; } const dependencyWorkspace = Yarn.workspace({ ident: dependencyIdent }); + if (!dependencyWorkspace) { + expectWorkspaceField(workspace, `devDependencies["${dependencyIdent}"]`); + } + } +} + +/** + * Expect that if packages which contain controllers or services are listed as + * peer+dev dependencies they are instead listed as direct dependencies. + * + * @param {Yarn} Yarn - The Yarn "global". + * @param {Workspace} workspace - The workspace to check. + * @param {Map>} dependenciesByIdentAndType - Map of + * dependency ident to dependency type and dependency. + */ +function expectDependenciesForControllersAndServices( + Yarn, + workspace, + dependenciesByIdentAndType, +) { + for (const [ + dependencyIdent, + dependencyInstancesByType, + ] of dependenciesByIdentAndType.entries()) { + if (dependencyIdent === '@metamask/eth-block-tracker') { + // Some packages have a peer dependency on this package, and we are still + // working through removing this peer dependency across packages. + continue; + } + + const peerDependency = dependencyInstancesByType.get('peerDependencies'); + const devDependency = dependencyInstancesByType.get('devDependencies'); + + if (!peerDependency) { + continue; + } + + const dependencyWorkspace = Yarn.workspace({ ident: peerDependency.ident }); + /** @type {string | undefined} */ + let targetVersion; if (dependencyWorkspace) { + targetVersion = `^${dependencyWorkspace.manifest.version}`; + } else if (/-(?:controller|service)s?$/u.test(dependencyIdent)) { + targetVersion = peerDependency.range; + } + + if (targetVersion === undefined) { + continue; + } + + expectWorkspaceField( + workspace, + `dependencies["${dependencyIdent}"]`, + targetVersion, + ); + + peerDependency.delete(); + + if (devDependency) { + devDependency.delete(); + } else { expectWorkspaceField( workspace, `devDependencies["${dependencyIdent}"]`, - `^${dependencyWorkspace.manifest.version}`, + null, ); - } else { - expectWorkspaceField(workspace, `devDependencies["${dependencyIdent}"]`); } } } @@ -768,8 +834,8 @@ function expectPeerDependenciesAlsoListedAsDevDependencies( /** * Filter out dependency ranges which are not to be considered in `expectConsistentDependenciesAndDevDependencies`. * - * @param {string} dependencyIdent - The dependency being filtered for - * @param {Map} dependenciesByRange - Dependencies by range + * @param {string} dependencyIdent - The dependency being filtered for. + * @param {Map} dependenciesByRange - Dependencies by range. * @returns {Map} The resulting map. */ function getInconsistentDependenciesAndDevDependencies( diff --git a/yarn.lock b/yarn.lock index 5f8e9c94c53..f3bf4a63e81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -755,7 +755,7 @@ __metadata: languageName: node linkType: hard -"@eslint-community/eslint-utils@npm:^4.1.2, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.4.1, @eslint-community/eslint-utils@npm:^4.8.0": +"@eslint-community/eslint-utils@npm:^4.1.2, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.4.1, @eslint-community/eslint-utils@npm:^4.7.0, @eslint-community/eslint-utils@npm:^4.8.0": version: 4.9.0 resolution: "@eslint-community/eslint-utils@npm:4.9.0" dependencies: @@ -2378,10 +2378,10 @@ __metadata: languageName: node linkType: hard -"@metamask/7715-permission-types@npm:^0.3.0": - version: 0.3.0 - resolution: "@metamask/7715-permission-types@npm:0.3.0" - checksum: 10/d30e2a12142555752a60ac1284a094cd0092c2cb1fde93467bb93adc34ed6485e4ac956af90a72e314c19faf69826737003431536fe4e89cb73cd407a34e1c8c +"@metamask/7715-permission-types@npm:^0.4.0": + version: 0.4.0 + resolution: "@metamask/7715-permission-types@npm:0.4.0" + checksum: 10/70748053e7b9fcd89d044a7602b48168137cc0761f8606ad17fdaafc13ac69271a75230c727a64df49dfedc59a0b3f60bd6fa46c60e06df40691985b9823881d languageName: node linkType: hard @@ -2447,13 +2447,7 @@ __metadata: typescript: "npm:~5.3.3" webextension-polyfill: "npm:^0.12.0" peerDependencies: - "@metamask/account-api": ^0.12.0 - "@metamask/accounts-controller": ^35.0.0 - "@metamask/keyring-controller": ^25.0.0 - "@metamask/multichain-account-service": ^4.0.0 - "@metamask/profile-sync-controller": ^27.0.0 "@metamask/providers": ^22.0.0 - "@metamask/snaps-controllers": ^14.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 languageName: unknown linkType: soft @@ -2472,7 +2466,7 @@ __metadata: "@metamask/keyring-internal-api": "npm:^9.0.0" "@metamask/keyring-utils": "npm:^3.1.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/providers": "npm:^22.1.0" "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/snaps-sdk": "npm:^9.0.0" @@ -2494,10 +2488,7 @@ __metadata: uuid: "npm:^8.3.2" webextension-polyfill: "npm:^0.12.0" peerDependencies: - "@metamask/keyring-controller": ^25.0.0 - "@metamask/network-controller": ^26.0.0 "@metamask/providers": ^22.0.0 - "@metamask/snaps-controllers": ^14.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 languageName: unknown linkType: soft @@ -2631,7 +2622,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controllers@npm:^90.0.0, @metamask/assets-controllers@workspace:packages/assets-controllers": +"@metamask/assets-controllers@npm:^93.1.0, @metamask/assets-controllers@workspace:packages/assets-controllers": version: 0.0.0-use.local resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers" dependencies: @@ -2661,17 +2652,18 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/multichain-account-service": "npm:^4.0.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/permission-controller": "npm:^12.1.1" - "@metamask/phishing-controller": "npm:^16.0.0" + "@metamask/phishing-controller": "npm:^16.1.0" "@metamask/polling-controller": "npm:^16.0.0" "@metamask/preferences-controller": "npm:^22.0.0" + "@metamask/profile-sync-controller": "npm:^27.0.0" "@metamask/providers": "npm:^22.1.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/snaps-sdk": "npm:^9.0.0" "@metamask/snaps-utils": "npm:^11.0.0" - "@metamask/transaction-controller": "npm:^62.0.0" + "@metamask/transaction-controller": "npm:^62.5.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/bn.js": "npm:^5.1.5" @@ -2699,18 +2691,7 @@ __metadata: uuid: "npm:^8.3.2" webextension-polyfill: "npm:^0.12.0" peerDependencies: - "@metamask/account-tree-controller": ^4.0.0 - "@metamask/accounts-controller": ^35.0.0 - "@metamask/approval-controller": ^8.0.0 - "@metamask/core-backend": ^5.0.0 - "@metamask/keyring-controller": ^25.0.0 - "@metamask/network-controller": ^26.0.0 - "@metamask/permission-controller": ^12.0.0 - "@metamask/phishing-controller": ^16.0.0 - "@metamask/preferences-controller": ^22.0.0 "@metamask/providers": ^22.0.0 - "@metamask/snaps-controllers": ^14.0.0 - "@metamask/transaction-controller": ^62.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 languageName: unknown linkType: soft @@ -2813,7 +2794,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^62.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^64.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -2823,7 +2804,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^35.0.0" - "@metamask/assets-controllers": "npm:^90.0.0" + "@metamask/assets-controllers": "npm:^93.1.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.16.0" @@ -2833,12 +2814,12 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/multichain-network-controller": "npm:^3.0.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/polling-controller": "npm:^16.0.0" - "@metamask/remote-feature-flag-controller": "npm:^2.0.1" + "@metamask/remote-feature-flag-controller": "npm:^3.0.0" "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^62.0.0" + "@metamask/transaction-controller": "npm:^62.5.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" @@ -2854,31 +2835,24 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/accounts-controller": ^35.0.0 - "@metamask/assets-controllers": ^90.0.0 - "@metamask/network-controller": ^26.0.0 - "@metamask/remote-feature-flag-controller": ^2.0.0 - "@metamask/snaps-controllers": ^14.0.0 - "@metamask/transaction-controller": ^62.0.0 languageName: unknown linkType: soft -"@metamask/bridge-status-controller@npm:^62.0.0, @metamask/bridge-status-controller@workspace:packages/bridge-status-controller": +"@metamask/bridge-status-controller@npm:^64.0.1, @metamask/bridge-status-controller@workspace:packages/bridge-status-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-status-controller@workspace:packages/bridge-status-controller" dependencies: "@metamask/accounts-controller": "npm:^35.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bridge-controller": "npm:^62.0.0" + "@metamask/bridge-controller": "npm:^64.0.0" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/gas-fee-controller": "npm:^26.0.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/polling-controller": "npm:^16.0.0" "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^62.0.0" + "@metamask/transaction-controller": "npm:^62.5.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" @@ -2893,13 +2867,6 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/accounts-controller": ^35.0.0 - "@metamask/bridge-controller": ^62.0.0 - "@metamask/gas-fee-controller": ^26.0.0 - "@metamask/network-controller": ^26.0.0 - "@metamask/snaps-controllers": ^14.0.0 - "@metamask/transaction-controller": ^62.0.0 languageName: unknown linkType: soft @@ -2930,7 +2897,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/chain-agnostic-permission@npm:^1.2.2, @metamask/chain-agnostic-permission@workspace:packages/chain-agnostic-permission": +"@metamask/chain-agnostic-permission@npm:^1.3.0, @metamask/chain-agnostic-permission@workspace:packages/chain-agnostic-permission": version: 0.0.0-use.local resolution: "@metamask/chain-agnostic-permission@workspace:packages/chain-agnostic-permission" dependencies: @@ -2938,7 +2905,7 @@ __metadata: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/keyring-internal-api": "npm:^9.0.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/permission-controller": "npm:^12.1.1" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.8.1" @@ -3059,9 +3026,6 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/accounts-controller": ^35.0.0 - "@metamask/keyring-controller": ^25.0.0 languageName: unknown linkType: soft @@ -3075,22 +3039,22 @@ __metadata: "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" "@metamask/create-release-branch": "npm:^4.1.3" - "@metamask/eslint-config": "npm:^14.1.0" - "@metamask/eslint-config-jest": "npm:^14.1.0" - "@metamask/eslint-config-nodejs": "npm:^14.0.0" - "@metamask/eslint-config-typescript": "npm:^14.1.0" + "@metamask/eslint-config": "npm:^15.0.0" + "@metamask/eslint-config-jest": "npm:^15.0.0" + "@metamask/eslint-config-nodejs": "npm:^15.0.0" + "@metamask/eslint-config-typescript": "npm:^15.0.0" "@metamask/eth-block-tracker": "npm:^15.0.0" "@metamask/eth-json-rpc-provider": "npm:^6.0.0" "@metamask/json-rpc-engine": "npm:^10.2.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" "@types/lodash": "npm:^4.14.191" "@types/node": "npm:^16.18.54" "@types/semver": "npm:^7" - "@typescript-eslint/eslint-plugin": "npm:^8.7.0" - "@typescript-eslint/parser": "npm:^8.7.0" + "@typescript-eslint/eslint-plugin": "npm:^8.48.0" + "@typescript-eslint/parser": "npm:^8.48.0" "@yarnpkg/types": "npm:^4.0.0" babel-jest: "npm:^29.7.0" depcheck: "npm:^1.4.7" @@ -3117,7 +3081,7 @@ __metadata: simple-git-hooks: "npm:^2.8.0" tsx: "npm:^4.20.5" typescript: "npm:~5.3.3" - typescript-eslint: "npm:^8.7.0" + typescript-eslint: "npm:^8.48.0" yargs: "npm:^17.7.2" languageName: unknown linkType: soft @@ -3165,9 +3129,6 @@ __metadata: typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - peerDependencies: - "@metamask/accounts-controller": ^35.0.0 - "@metamask/keyring-controller": ^25.0.0 languageName: unknown linkType: soft @@ -3201,9 +3162,9 @@ __metadata: "@metamask/controller-utils": "npm:^11.16.0" "@metamask/keyring-api": "npm:^21.0.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/stake-sdk": "npm:^3.2.1" - "@metamask/transaction-controller": "npm:^62.0.0" + "@metamask/transaction-controller": "npm:^62.5.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" @@ -3213,9 +3174,6 @@ __metadata: typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - peerDependencies: - "@metamask/account-tree-controller": ^4.0.0 - "@metamask/network-controller": ^26.0.0 languageName: unknown linkType: soft @@ -3228,7 +3186,7 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^62.0.0" + "@metamask/transaction-controller": "npm:^62.5.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" @@ -3269,7 +3227,7 @@ __metadata: resolution: "@metamask/eip1193-permission-middleware@workspace:packages/eip1193-permission-middleware" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/chain-agnostic-permission": "npm:^1.2.2" + "@metamask/chain-agnostic-permission": "npm:^1.3.0" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/json-rpc-engine": "npm:^10.2.0" "@metamask/permission-controller": "npm:^12.1.1" @@ -3296,7 +3254,7 @@ __metadata: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" @@ -3307,8 +3265,6 @@ __metadata: typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - peerDependencies: - "@metamask/network-controller": ^26.0.0 languageName: unknown linkType: soft @@ -3331,54 +3287,54 @@ __metadata: languageName: unknown linkType: soft -"@metamask/eslint-config-jest@npm:^14.1.0": - version: 14.1.0 - resolution: "@metamask/eslint-config-jest@npm:14.1.0" +"@metamask/eslint-config-jest@npm:^15.0.0": + version: 15.0.0 + resolution: "@metamask/eslint-config-jest@npm:15.0.0" dependencies: "@eslint/js": "npm:^9.11.0" globals: "npm:^15.9.0" peerDependencies: - "@metamask/eslint-config": ^14.1.0 + "@metamask/eslint-config": ^15.0.0 eslint: ^9.11.0 eslint-plugin-jest: ^28.8.3 - checksum: 10/2c5bd99fb4470206b47360566f1681c93ed2254080297e2fa34392eb5ae64470138e2f67171a09bb6051e6a4a69eaf430f68b82ef8886604b368cc4129c80462 + checksum: 10/4728c6b80bed48b9e369ed538de2a1f099a74825f08e7a1c0c5df53901905ce6d54fb7c77da4454ed640af7fd873845ab0f084a4f370fe26ccb4455e683e6bba languageName: node linkType: hard -"@metamask/eslint-config-nodejs@npm:^14.0.0": - version: 14.0.0 - resolution: "@metamask/eslint-config-nodejs@npm:14.0.0" +"@metamask/eslint-config-nodejs@npm:^15.0.0": + version: 15.0.0 + resolution: "@metamask/eslint-config-nodejs@npm:15.0.0" dependencies: "@eslint/js": "npm:^9.11.0" globals: "npm:^15.9.0" peerDependencies: - "@metamask/eslint-config": ^14.0.0 + "@metamask/eslint-config": ^15.0.0 eslint: ^9.11.0 eslint-plugin-n: ^17.10.3 - checksum: 10/62a69e0a258b6b0ef8cbb844a3420115ff213648f55e1b3863dd29fa5892de8013f8157317e8279f68b7e82c69c97edc15c0040ad49469756393a711d91b0fff + checksum: 10/541a2df5a21e3e73abd8b5b175fd2c3604bfa8694d7e8e7e48891f01d38396267f1cf92cb88cb181ddd7931dee3c7bf39de59d2cb1f30971a245551d575674a1 languageName: node linkType: hard -"@metamask/eslint-config-typescript@npm:^14.1.0": - version: 14.1.0 - resolution: "@metamask/eslint-config-typescript@npm:14.1.0" +"@metamask/eslint-config-typescript@npm:^15.0.0": + version: 15.0.0 + resolution: "@metamask/eslint-config-typescript@npm:15.0.0" dependencies: "@eslint/js": "npm:^9.11.0" peerDependencies: - "@metamask/eslint-config": ^14.1.0 + "@metamask/eslint-config": ^15.0.0 eslint: ^9.11.0 eslint-import-resolver-typescript: ^3.6.3 eslint-plugin-import-x: ^4.3.0 eslint-plugin-jsdoc: ^50.2.4 - typescript: ">=4.8.4 <5.9.0" - typescript-eslint: ^8.24 - checksum: 10/697b61648969f5f53179b8cf83ffb1aa1dbe5ce9ad4f7f4ed0bc4e436c510f1d28543e764467fd880ccb2579b5810e78eee63f972daa55f1b599844b53ea13ca + typescript: ">=4.8.4 <6" + typescript-eslint: ^8.39.0 + checksum: 10/5ff44e8970a67f87da92a65b8478d22374713ca94feb671ba462b87441ae8b47e427857b363b79b50e6078856c0f482c3965de7f2ee38fe0b40f8f4e27891540 languageName: node linkType: hard -"@metamask/eslint-config@npm:^14.1.0": - version: 14.1.0 - resolution: "@metamask/eslint-config@npm:14.1.0" +"@metamask/eslint-config@npm:^15.0.0": + version: 15.0.0 + resolution: "@metamask/eslint-config@npm:15.0.0" dependencies: "@eslint/js": "npm:^9.11.0" globals: "npm:^15.9.0" @@ -3390,7 +3346,7 @@ __metadata: eslint-plugin-prettier: ^5.2.1 eslint-plugin-promise: ^7.1.0 prettier: ^3.3.3 - checksum: 10/c6313391ea09130ae7254356069c8c28621d8dac668278291cba4436e95d4d5b8a43e11d7ce98ade96b2e4c7706171eba9c966ce7ba439fe888576bb32930b06 + checksum: 10/93eb41bd61f3f4a0cf930a3d83e8893455f4f03339e91b21ef5a2835d24532db7c2916138a3e1b64777b5fb95cbe4ac90107697ac6f962c11ea7a99955cc75e4 languageName: node linkType: hard @@ -3456,7 +3412,7 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-json-rpc-middleware@npm:^22.0.0, @metamask/eth-json-rpc-middleware@workspace:packages/eth-json-rpc-middleware": +"@metamask/eth-json-rpc-middleware@npm:^22.0.1, @metamask/eth-json-rpc-middleware@workspace:packages/eth-json-rpc-middleware": version: 0.0.0-use.local resolution: "@metamask/eth-json-rpc-middleware@workspace:packages/eth-json-rpc-middleware" dependencies: @@ -3467,7 +3423,7 @@ __metadata: "@metamask/eth-sig-util": "npm:^8.2.0" "@metamask/json-rpc-engine": "npm:^10.2.0" "@metamask/message-manager": "npm:^14.1.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.8.1" @@ -3766,7 +3722,7 @@ __metadata: "@metamask/controller-utils": "npm:^11.16.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-unit": "npm:^0.3.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/polling-controller": "npm:^16.0.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" @@ -3787,17 +3743,16 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@babel/runtime": ^7.0.0 - "@metamask/network-controller": ^26.0.0 languageName: unknown linkType: soft -"@metamask/gator-permissions-controller@npm:^0.6.0, @metamask/gator-permissions-controller@workspace:packages/gator-permissions-controller": +"@metamask/gator-permissions-controller@npm:^0.8.0, @metamask/gator-permissions-controller@workspace:packages/gator-permissions-controller": version: 0.0.0-use.local resolution: "@metamask/gator-permissions-controller@workspace:packages/gator-permissions-controller" dependencies: "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" - "@metamask/7715-permission-types": "npm:^0.3.0" + "@metamask/7715-permission-types": "npm:^0.4.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/delegation-core": "npm:^0.2.0" @@ -3806,7 +3761,7 @@ __metadata: "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/snaps-sdk": "npm:^9.0.0" "@metamask/snaps-utils": "npm:^11.0.0" - "@metamask/transaction-controller": "npm:^62.0.0" + "@metamask/transaction-controller": "npm:^62.5.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" @@ -3816,9 +3771,6 @@ __metadata: typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - peerDependencies: - "@metamask/snaps-controllers": ^14.0.1 - "@metamask/transaction-controller": ^62.0.0 languageName: unknown linkType: soft @@ -4111,11 +4063,7 @@ __metadata: webextension-polyfill: "npm:^0.12.0" peerDependencies: "@metamask/account-api": ^0.12.0 - "@metamask/accounts-controller": ^35.0.0 - "@metamask/error-reporting-service": ^3.0.0 - "@metamask/keyring-controller": ^25.0.0 "@metamask/providers": ^22.0.0 - "@metamask/snaps-controllers": ^14.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 languageName: unknown linkType: soft @@ -4126,12 +4074,12 @@ __metadata: dependencies: "@metamask/api-specs": "npm:^0.14.0" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/chain-agnostic-permission": "npm:^1.2.2" + "@metamask/chain-agnostic-permission": "npm:^1.3.0" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/eth-json-rpc-filters": "npm:^9.0.0" "@metamask/json-rpc-engine": "npm:^10.2.0" "@metamask/multichain-transactions-controller": "npm:^7.0.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/permission-controller": "npm:^12.1.1" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/safe-event-emitter": "npm:^3.0.0" @@ -4162,7 +4110,7 @@ __metadata: "@metamask/keyring-controller": "npm:^25.0.0" "@metamask/keyring-internal-api": "npm:^9.0.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.8.1" "@solana/addresses": "npm:^2.0.0" @@ -4179,9 +4127,6 @@ __metadata: typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - peerDependencies: - "@metamask/accounts-controller": ^35.0.0 - "@metamask/network-controller": ^26.0.0 languageName: unknown linkType: soft @@ -4213,9 +4158,6 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/accounts-controller": ^35.0.0 - "@metamask/snaps-controllers": ^14.0.0 languageName: unknown linkType: soft @@ -4240,7 +4182,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/network-controller@npm:^26.0.0, @metamask/network-controller@workspace:packages/network-controller": +"@metamask/network-controller@npm:^27.0.0, @metamask/network-controller@workspace:packages/network-controller": version: 0.0.0-use.local resolution: "@metamask/network-controller@workspace:packages/network-controller" dependencies: @@ -4251,7 +4193,7 @@ __metadata: "@metamask/error-reporting-service": "npm:^3.0.0" "@metamask/eth-block-tracker": "npm:^15.0.0" "@metamask/eth-json-rpc-infura": "npm:^10.3.0" - "@metamask/eth-json-rpc-middleware": "npm:^22.0.0" + "@metamask/eth-json-rpc-middleware": "npm:^22.0.1" "@metamask/eth-json-rpc-provider": "npm:^6.0.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/json-rpc-engine": "npm:^10.2.0" @@ -4266,6 +4208,7 @@ __metadata: "@types/lodash": "npm:^4.14.191" "@types/node-fetch": "npm:^2.6.12" async-mutex: "npm:^0.5.0" + cockatiel: "npm:^3.1.2" deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" fast-deep-equal: "npm:^3.1.3" @@ -4284,8 +4227,6 @@ __metadata: typescript: "npm:~5.3.3" uri-js: "npm:^4.4.1" uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/error-reporting-service": ^3.0.0 languageName: unknown linkType: soft @@ -4299,8 +4240,8 @@ __metadata: "@metamask/keyring-api": "npm:^21.0.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/multichain-network-controller": "npm:^3.0.0" - "@metamask/network-controller": "npm:^26.0.0" - "@metamask/transaction-controller": "npm:^62.0.0" + "@metamask/network-controller": "npm:^27.0.0" + "@metamask/transaction-controller": "npm:^62.5.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" @@ -4312,10 +4253,6 @@ __metadata: typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - peerDependencies: - "@metamask/multichain-network-controller": ^3.0.0 - "@metamask/network-controller": ^26.0.0 - "@metamask/transaction-controller": ^62.0.0 languageName: unknown linkType: soft @@ -4363,9 +4300,6 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/keyring-controller": ^25.0.0 - "@metamask/profile-sync-controller": ^27.0.0 languageName: unknown linkType: soft @@ -4432,8 +4366,6 @@ __metadata: typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - peerDependencies: - "@metamask/approval-controller": ^8.0.0 languageName: unknown linkType: soft @@ -4475,7 +4407,7 @@ __metadata: languageName: node linkType: hard -"@metamask/phishing-controller@npm:^16.0.0, @metamask/phishing-controller@workspace:packages/phishing-controller": +"@metamask/phishing-controller@npm:^16.1.0, @metamask/phishing-controller@workspace:packages/phishing-controller": version: 0.0.0-use.local resolution: "@metamask/phishing-controller@workspace:packages/phishing-controller" dependencies: @@ -4483,7 +4415,7 @@ __metadata: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/transaction-controller": "npm:^62.0.0" + "@metamask/transaction-controller": "npm:^62.5.0" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" @@ -4499,8 +4431,6 @@ __metadata: typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - peerDependencies: - "@metamask/transaction-controller": ^62.0.0 languageName: unknown linkType: soft @@ -4511,7 +4441,7 @@ __metadata: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.16.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" @@ -4525,8 +4455,6 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/network-controller": ^26.0.0 languageName: unknown linkType: soft @@ -4559,8 +4487,34 @@ __metadata: typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - peerDependencies: - "@metamask/keyring-controller": ^25.0.0 + languageName: unknown + linkType: soft + +"@metamask/profile-metrics-controller@workspace:packages/profile-metrics-controller": + version: 0.0.0-use.local + resolution: "@metamask/profile-metrics-controller@workspace:packages/profile-metrics-controller" + dependencies: + "@metamask/accounts-controller": "npm:^35.0.0" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/keyring-controller": "npm:^25.0.0" + "@metamask/keyring-internal-api": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/polling-controller": "npm:^16.0.0" + "@metamask/profile-sync-controller": "npm:^27.0.0" + "@metamask/utils": "npm:^11.8.1" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^27.4.1" + async-mutex: "npm:^0.5.0" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + nock: "npm:^13.3.1" + sinon: "npm:^9.2.4" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" languageName: unknown linkType: soft @@ -4600,10 +4554,7 @@ __metadata: typescript: "npm:~5.3.3" webextension-polyfill: "npm:^0.12.0" peerDependencies: - "@metamask/address-book-controller": ^7.0.1 - "@metamask/keyring-controller": ^25.0.0 "@metamask/providers": ^22.0.0 - "@metamask/snaps-controllers": ^14.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 languageName: unknown linkType: soft @@ -4649,7 +4600,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/remote-feature-flag-controller@npm:^2.0.1, @metamask/remote-feature-flag-controller@workspace:packages/remote-feature-flag-controller": +"@metamask/remote-feature-flag-controller@npm:^3.0.0, @metamask/remote-feature-flag-controller@workspace:packages/remote-feature-flag-controller": version: 0.0.0-use.local resolution: "@metamask/remote-feature-flag-controller@workspace:packages/remote-feature-flag-controller" dependencies: @@ -4697,7 +4648,7 @@ __metadata: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" @@ -4709,8 +4660,6 @@ __metadata: typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - peerDependencies: - "@metamask/network-controller": ^26.0.0 languageName: unknown linkType: soft @@ -4736,7 +4685,7 @@ __metadata: "@metamask/browser-passworder": "npm:^6.0.0" "@metamask/keyring-controller": "npm:^25.0.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/toprf-secure-backup": "npm:^0.10.0" + "@metamask/toprf-secure-backup": "npm:^0.11.0" "@metamask/utils": "npm:^11.8.1" "@noble/ciphers": "npm:^1.3.0" "@noble/curves": "npm:^1.9.2" @@ -4754,8 +4703,6 @@ __metadata: typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - peerDependencies: - "@metamask/keyring-controller": ^25.0.0 languageName: unknown linkType: soft @@ -4767,7 +4714,7 @@ __metadata: "@metamask/base-controller": "npm:^9.0.0" "@metamask/json-rpc-engine": "npm:^10.2.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/permission-controller": "npm:^12.1.1" "@metamask/swappable-obj-proxy": "npm:^2.3.0" "@metamask/utils": "npm:^11.8.1" @@ -4783,9 +4730,6 @@ __metadata: typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - peerDependencies: - "@metamask/network-controller": ^26.0.0 - "@metamask/permission-controller": ^12.0.0 languageName: unknown linkType: soft @@ -4800,8 +4744,8 @@ __metadata: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/signature-controller": "npm:^37.0.0" - "@metamask/transaction-controller": "npm:^62.0.0" + "@metamask/signature-controller": "npm:^38.0.0" + "@metamask/transaction-controller": "npm:^62.5.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" @@ -4814,13 +4758,10 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/signature-controller": ^37.0.0 - "@metamask/transaction-controller": ^62.0.0 languageName: unknown linkType: soft -"@metamask/signature-controller@npm:^37.0.0, @metamask/signature-controller@workspace:packages/signature-controller": +"@metamask/signature-controller@npm:^38.0.0, @metamask/signature-controller@workspace:packages/signature-controller": version: 0.0.0-use.local resolution: "@metamask/signature-controller@workspace:packages/signature-controller" dependencies: @@ -4830,11 +4771,11 @@ __metadata: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/gator-permissions-controller": "npm:^0.6.0" + "@metamask/gator-permissions-controller": "npm:^0.8.0" "@metamask/keyring-controller": "npm:^25.0.0" "@metamask/logging-controller": "npm:^7.0.1" "@metamask/messenger": "npm:^0.3.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" @@ -4847,13 +4788,6 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/accounts-controller": ^35.0.0 - "@metamask/approval-controller": ^8.0.0 - "@metamask/gator-permissions-controller": ^0.6.0 - "@metamask/keyring-controller": ^25.0.0 - "@metamask/logging-controller": ^7.0.0 - "@metamask/network-controller": ^26.0.0 languageName: unknown linkType: soft @@ -4985,6 +4919,24 @@ __metadata: languageName: node linkType: hard +"@metamask/storage-service@workspace:packages/storage-service": + version: 0.0.0-use.local + resolution: "@metamask/storage-service@workspace:packages/storage-service" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.8.1" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^27.4.1" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/subscription-controller@workspace:packages/subscription-controller": version: 0.0.0-use.local resolution: "@metamask/subscription-controller@workspace:packages/subscription-controller" @@ -4995,7 +4947,7 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/polling-controller": "npm:^16.0.0" "@metamask/profile-sync-controller": "npm:^27.0.0" - "@metamask/transaction-controller": "npm:^62.0.0" + "@metamask/transaction-controller": "npm:^62.5.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" @@ -5007,8 +4959,6 @@ __metadata: typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - peerDependencies: - "@metamask/profile-sync-controller": ^27.0.0 languageName: unknown linkType: soft @@ -5046,9 +4996,9 @@ __metadata: languageName: unknown linkType: soft -"@metamask/toprf-secure-backup@npm:^0.10.0": - version: 0.10.0 - resolution: "@metamask/toprf-secure-backup@npm:0.10.0" +"@metamask/toprf-secure-backup@npm:^0.11.0": + version: 0.11.0 + resolution: "@metamask/toprf-secure-backup@npm:0.11.0" dependencies: "@metamask/auth-network-utils": "npm:^0.4.0" "@noble/ciphers": "npm:^1.2.1" @@ -5060,11 +5010,11 @@ __metadata: "@toruslabs/fetch-node-details": "npm:^15.0.0" "@toruslabs/http-helpers": "npm:^8.1.1" bn.js: "npm:^5.2.2" - checksum: 10/07d6e9d96072a79de1ae0b60cea6dc1e593286a72739e865043ddd14e50b106b70193f44865f5ade191de0fdf87c3b1e14062ed1f6479a03da40d5c1bf4c98d8 + checksum: 10/15ff309c59c978e4dc6939337d0384b914778e15f222fc52dac1b3b43705e741403e4648aadecb88c4c47a1c5e07fd2b97699757f79decd5a659e1968504ef7f languageName: node linkType: hard -"@metamask/transaction-controller@npm:^62.0.0, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@npm:^62.5.0, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: @@ -5088,9 +5038,9 @@ __metadata: "@metamask/gas-fee-controller": "npm:^26.0.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/nonce-tracker": "npm:^6.0.0" - "@metamask/remote-feature-flag-controller": "npm:^2.0.1" + "@metamask/remote-feature-flag-controller": "npm:^3.0.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" @@ -5115,12 +5065,7 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@babel/runtime": ^7.0.0 - "@metamask/accounts-controller": ^35.0.0 - "@metamask/approval-controller": ^8.0.0 "@metamask/eth-block-tracker": ">=9" - "@metamask/gas-fee-controller": ^26.0.0 - "@metamask/network-controller": ^26.0.0 - "@metamask/remote-feature-flag-controller": ^2.0.0 languageName: unknown linkType: soft @@ -5130,18 +5075,18 @@ __metadata: dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" - "@metamask/assets-controllers": "npm:^90.0.0" + "@metamask/assets-controllers": "npm:^93.1.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bridge-controller": "npm:^62.0.0" - "@metamask/bridge-status-controller": "npm:^62.0.0" + "@metamask/bridge-controller": "npm:^64.0.0" + "@metamask/bridge-status-controller": "npm:^64.0.1" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/gas-fee-controller": "npm:^26.0.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^26.0.0" - "@metamask/remote-feature-flag-controller": "npm:^2.0.1" - "@metamask/transaction-controller": "npm:^62.0.0" + "@metamask/network-controller": "npm:^27.0.0" + "@metamask/remote-feature-flag-controller": "npm:^3.0.0" + "@metamask/transaction-controller": "npm:^62.5.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" @@ -5155,14 +5100,6 @@ __metadata: typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - peerDependencies: - "@metamask/assets-controllers": ^90.0.0 - "@metamask/bridge-controller": ^62.0.0 - "@metamask/bridge-status-controller": ^62.0.0 - "@metamask/gas-fee-controller": ^26.0.0 - "@metamask/network-controller": ^26.0.0 - "@metamask/remote-feature-flag-controller": ^2.0.0 - "@metamask/transaction-controller": ^62.0.0 languageName: unknown linkType: soft @@ -5179,11 +5116,11 @@ __metadata: "@metamask/gas-fee-controller": "npm:^26.0.0" "@metamask/keyring-controller": "npm:^25.0.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/polling-controller": "npm:^16.0.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^62.0.0" + "@metamask/transaction-controller": "npm:^62.5.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" @@ -5198,12 +5135,7 @@ __metadata: typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" peerDependencies: - "@metamask/approval-controller": ^8.0.0 "@metamask/eth-block-tracker": ">=9" - "@metamask/gas-fee-controller": ^26.0.0 - "@metamask/keyring-controller": ^25.0.0 - "@metamask/network-controller": ^26.0.0 - "@metamask/transaction-controller": ^62.0.0 languageName: unknown linkType: soft @@ -6265,115 +6197,139 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.19.1, @typescript-eslint/eslint-plugin@npm:^8.7.0": - version: 8.19.1 - resolution: "@typescript-eslint/eslint-plugin@npm:8.19.1" +"@typescript-eslint/eslint-plugin@npm:8.48.0, @typescript-eslint/eslint-plugin@npm:^8.48.0": + version: 8.48.0 + resolution: "@typescript-eslint/eslint-plugin@npm:8.48.0" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:8.19.1" - "@typescript-eslint/type-utils": "npm:8.19.1" - "@typescript-eslint/utils": "npm:8.19.1" - "@typescript-eslint/visitor-keys": "npm:8.19.1" + "@typescript-eslint/scope-manager": "npm:8.48.0" + "@typescript-eslint/type-utils": "npm:8.48.0" + "@typescript-eslint/utils": "npm:8.48.0" + "@typescript-eslint/visitor-keys": "npm:8.48.0" graphemer: "npm:^1.4.0" - ignore: "npm:^5.3.1" + ignore: "npm:^7.0.0" natural-compare: "npm:^1.4.0" - ts-api-utils: "npm:^2.0.0" + ts-api-utils: "npm:^2.1.0" peerDependencies: - "@typescript-eslint/parser": ^8.0.0 || ^8.0.0-alpha.0 + "@typescript-eslint/parser": ^8.48.0 eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.8.0" - checksum: 10/c9a6d3181ec01068075b85ad3ac454910b4452281d60c775cc7229827f6d6a076b7336f5f07a7ad89bf08b3224f6a49aa20342b9438702393bee0aa7315d23b2 + typescript: ">=4.8.4 <6.0.0" + checksum: 10/c9cd87c72da7bb7f6175fdb53a4c08a26e61a3d9d1024960d193276217b37ca1e8e12328a57751ed9380475e11e198f9715e172126ea7d3b3da9948d225db92b languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.19.1, @typescript-eslint/parser@npm:^8.7.0": - version: 8.19.1 - resolution: "@typescript-eslint/parser@npm:8.19.1" +"@typescript-eslint/parser@npm:8.48.0, @typescript-eslint/parser@npm:^8.48.0": + version: 8.48.0 + resolution: "@typescript-eslint/parser@npm:8.48.0" dependencies: - "@typescript-eslint/scope-manager": "npm:8.19.1" - "@typescript-eslint/types": "npm:8.19.1" - "@typescript-eslint/typescript-estree": "npm:8.19.1" - "@typescript-eslint/visitor-keys": "npm:8.19.1" + "@typescript-eslint/scope-manager": "npm:8.48.0" + "@typescript-eslint/types": "npm:8.48.0" + "@typescript-eslint/typescript-estree": "npm:8.48.0" + "@typescript-eslint/visitor-keys": "npm:8.48.0" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.8.0" - checksum: 10/da3db63ff655cf0fb91745ba8e52d853386f601cf6106d36f4541efcb9e2c6c3b82c6743b15680eff9eafeccaf31c9b26191a955e66ae19de9172f67335463ab + typescript: ">=4.8.4 <6.0.0" + checksum: 10/5919642345c79a43e57a85e0e69d1f56b5756b3fdb3586ec6371969604f589adc188338c8f12a787456edc3b38c70586d8209cffcf45e35e5a5ebd497c5f4257 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.19.1, @typescript-eslint/scope-manager@npm:^8.1.0": - version: 8.19.1 - resolution: "@typescript-eslint/scope-manager@npm:8.19.1" +"@typescript-eslint/project-service@npm:8.48.0": + version: 8.48.0 + resolution: "@typescript-eslint/project-service@npm:8.48.0" dependencies: - "@typescript-eslint/types": "npm:8.19.1" - "@typescript-eslint/visitor-keys": "npm:8.19.1" - checksum: 10/6ffc78b15367f211eb6650459ca2bb6bfe4c1fa95a3474adc08ee9a20c250b2e0e02fd99be36bd3dad74967ecd9349e792b5d818d85735cba40f1b5c236074d1 + "@typescript-eslint/tsconfig-utils": "npm:^8.48.0" + "@typescript-eslint/types": "npm:^8.48.0" + debug: "npm:^4.3.4" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10/5853a2f57bf8a26b70c1fe5a906c1890ad4f0fca127218a7805161fc9ad547af97f4a600f32f5acdf2f2312b156affca2bea84af9a433215cbcc2056b6a27c77 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.19.1": - version: 8.19.1 - resolution: "@typescript-eslint/type-utils@npm:8.19.1" +"@typescript-eslint/scope-manager@npm:8.48.0, @typescript-eslint/scope-manager@npm:^8.1.0": + version: 8.48.0 + resolution: "@typescript-eslint/scope-manager@npm:8.48.0" dependencies: - "@typescript-eslint/typescript-estree": "npm:8.19.1" - "@typescript-eslint/utils": "npm:8.19.1" + "@typescript-eslint/types": "npm:8.48.0" + "@typescript-eslint/visitor-keys": "npm:8.48.0" + checksum: 10/963af7af235e940467504969c565b359ca454a156eba0d5af2e4fd9cca4294947187e1a85107ff05801688ac85b5767d2566414cbef47a03c23f7b46527decca + languageName: node + linkType: hard + +"@typescript-eslint/tsconfig-utils@npm:8.48.0, @typescript-eslint/tsconfig-utils@npm:^8.48.0": + version: 8.48.0 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.48.0" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10/e480cd80498c4119a8c5bc413a22abf4bf365b3674ff95f5513292ede31e4fd8118f50d76a786de702696396a43c0c7a4d0c2ccd1c2c7db61bd941ba74495021 + languageName: node + linkType: hard + +"@typescript-eslint/type-utils@npm:8.48.0": + version: 8.48.0 + resolution: "@typescript-eslint/type-utils@npm:8.48.0" + dependencies: + "@typescript-eslint/types": "npm:8.48.0" + "@typescript-eslint/typescript-estree": "npm:8.48.0" + "@typescript-eslint/utils": "npm:8.48.0" debug: "npm:^4.3.4" - ts-api-utils: "npm:^2.0.0" + ts-api-utils: "npm:^2.1.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.8.0" - checksum: 10/123ecda88b057d6a4b68226701f435661440a420fda88cba60b49d7fb3e4f49483164ff174f259e28c0beabb0ed04500462a20faefd78331ba202bf54b01e3ef + typescript: ">=4.8.4 <6.0.0" + checksum: 10/dfda42624d534f9fed270bd5c76c9c0bb879cccd3dfbfc2977c84489860fbc204f10bca5c69f3ac856cc4342c12f8947293e7449d3391af289620d7ec79ced0d languageName: node linkType: hard -"@typescript-eslint/types@npm:8.19.1": - version: 8.19.1 - resolution: "@typescript-eslint/types@npm:8.19.1" - checksum: 10/5833a5f8fdac4a490dd3906a0243a0713fbf138fabb451870c70b0b089c539a9624b467b0913ddc0a225a8284342e7fd31cd506dec53c1a6d8f3c8c8902b9cae +"@typescript-eslint/types@npm:8.48.0, @typescript-eslint/types@npm:^8.48.0": + version: 8.48.0 + resolution: "@typescript-eslint/types@npm:8.48.0" + checksum: 10/cd14a7ecd1cb6af94e059a713357b9521ffab08b2793a7d33abda7006816e77f634d49d1ec6f1b99b47257a605347d691bd02b2b11477c9c328f2a27f52a664f languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.19.1": - version: 8.19.1 - resolution: "@typescript-eslint/typescript-estree@npm:8.19.1" +"@typescript-eslint/typescript-estree@npm:8.48.0": + version: 8.48.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.48.0" dependencies: - "@typescript-eslint/types": "npm:8.19.1" - "@typescript-eslint/visitor-keys": "npm:8.19.1" + "@typescript-eslint/project-service": "npm:8.48.0" + "@typescript-eslint/tsconfig-utils": "npm:8.48.0" + "@typescript-eslint/types": "npm:8.48.0" + "@typescript-eslint/visitor-keys": "npm:8.48.0" debug: "npm:^4.3.4" - fast-glob: "npm:^3.3.2" - is-glob: "npm:^4.0.3" minimatch: "npm:^9.0.4" semver: "npm:^7.6.0" - ts-api-utils: "npm:^2.0.0" + tinyglobby: "npm:^0.2.15" + ts-api-utils: "npm:^2.1.0" peerDependencies: - typescript: ">=4.8.4 <5.8.0" - checksum: 10/5de467452d5ef1a380d441b06cd0134652a0c98cdb4ce31b93eb589f7dc75ef60364d03fd80ca0a48d0c8b268f7258d4f6528b16fe1b89442d60a4bc960fe5f5 + typescript: ">=4.8.4 <6.0.0" + checksum: 10/8ee6b9e98dd72d567b8842a695578b2098bd8cdcf5628d2819407a52b533a5a139ba9a5620976641bc4553144a1b971d75f2df218a7c281fe674df25835e9e22 languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.19.1, @typescript-eslint/utils@npm:^6.0.0 || ^7.0.0 || ^8.0.0, @typescript-eslint/utils@npm:^8.1.0": - version: 8.19.1 - resolution: "@typescript-eslint/utils@npm:8.19.1" +"@typescript-eslint/utils@npm:8.48.0, @typescript-eslint/utils@npm:^6.0.0 || ^7.0.0 || ^8.0.0, @typescript-eslint/utils@npm:^8.1.0": + version: 8.48.0 + resolution: "@typescript-eslint/utils@npm:8.48.0" dependencies: - "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:8.19.1" - "@typescript-eslint/types": "npm:8.19.1" - "@typescript-eslint/typescript-estree": "npm:8.19.1" + "@eslint-community/eslint-utils": "npm:^4.7.0" + "@typescript-eslint/scope-manager": "npm:8.48.0" + "@typescript-eslint/types": "npm:8.48.0" + "@typescript-eslint/typescript-estree": "npm:8.48.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.8.0" - checksum: 10/bb92116a53fe143ee87e830941afb21d4222a64ca3f2b6dac5c2d9984f981408e60e52b04c32d95208896075ac222fb4ee631c5b0c4826b87d4bd8091c421ab1 + typescript: ">=4.8.4 <6.0.0" + checksum: 10/980b9faeaae0357bd7c002b15ab3bbcb7d5e4558be5df7980cf5221b41570a1a7b7d71ea2fcc8b1387f6c0db948d01468e6dcb31230d6757e28ac2ee5d8be4cf languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.19.1": - version: 8.19.1 - resolution: "@typescript-eslint/visitor-keys@npm:8.19.1" +"@typescript-eslint/visitor-keys@npm:8.48.0": + version: 8.48.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.48.0" dependencies: - "@typescript-eslint/types": "npm:8.19.1" - eslint-visitor-keys: "npm:^4.2.0" - checksum: 10/510eb196e7b7d59d3981d672a75454615159e931fe78e2a64b09607c3cfa45110709b0eb5ac3dd271d757a0d98cf4868ad2f45bf9193f96e9efec3efa92a19c1 + "@typescript-eslint/types": "npm:8.48.0" + eslint-visitor-keys: "npm:^4.2.1" + checksum: 10/f9eaff8225b3b00e486e0221bd596b08a3ed463f31fab88221256908f6208c48f745281b7b92e6358d25e1dbdc37c6c2f4b42503403c24b071165bafd9a35d52 languageName: node linkType: hard @@ -8488,7 +8444,7 @@ __metadata: languageName: node linkType: hard -"eslint-visitor-keys@npm:^4.2.0, eslint-visitor-keys@npm:^4.2.1": +"eslint-visitor-keys@npm:^4.2.1": version: 4.2.1 resolution: "eslint-visitor-keys@npm:4.2.1" checksum: 10/3ee00fc6a7002d4b0ffd9dc99e13a6a7882c557329e6c25ab254220d71e5c9c4f89dca4695352949ea678eb1f3ba912a18ef8aac0a7fe094196fd92f441bfce2 @@ -9011,6 +8967,18 @@ __metadata: languageName: node linkType: hard +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10/14ca1c9f0a0e8f4f2e9bf4e8551065a164a09545dae548c12a18d238b72e51e5a7b39bd8e5494b56463a0877672d0a6c1ef62c6fa0677db1b0c847773be939b1 + languageName: node + linkType: hard + "file-entry-cache@npm:^8.0.0": version: 8.0.0 resolution: "file-entry-cache@npm:8.0.0" @@ -9743,13 +9711,20 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^5.2.0, ignore@npm:^5.2.4, ignore@npm:^5.3.1, ignore@npm:^5.3.2": +"ignore@npm:^5.2.0, ignore@npm:^5.2.4, ignore@npm:^5.3.2": version: 5.3.2 resolution: "ignore@npm:5.3.2" checksum: 10/cceb6a457000f8f6a50e1196429750d782afce5680dd878aa4221bd79972d68b3a55b4b1458fc682be978f4d3c6a249046aa0880637367216444ab7b014cfc98 languageName: node linkType: hard +"ignore@npm:^7.0.0": + version: 7.0.5 + resolution: "ignore@npm:7.0.5" + checksum: 10/f134b96a4de0af419196f52c529d5c6120c4456ff8a6b5a14ceaaa399f883e15d58d2ce651c9b69b9388491d4669dda47285d307e827de9304a53a1824801bc6 + languageName: node + linkType: hard + "immer@npm:^9.0.6": version: 9.0.21 resolution: "immer@npm:9.0.21" @@ -12154,6 +12129,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.3": + version: 4.0.3 + resolution: "picomatch@npm:4.0.3" + checksum: 10/57b99055f40b16798f2802916d9c17e9744e620a0db136554af01d19598b96e45e2f00014c91d1b8b13874b80caa8c295b3d589a3f72373ec4aaf54baa5962d5 + languageName: node + linkType: hard + "pify@npm:^5.0.0": version: 5.0.0 resolution: "pify@npm:5.0.0" @@ -13579,6 +13561,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.15": + version: 0.2.15 + resolution: "tinyglobby@npm:0.2.15" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.3" + checksum: 10/d72bd826a8b0fa5fa3929e7fe5ba48fceb2ae495df3a231b6c5408cd7d8c00b58ab5a9c2a76ba56a62ee9b5e083626f1f33599734bed1ffc4b792406408f0ca2 + languageName: node + linkType: hard + "tmpl@npm:1.0.5": version: 1.0.5 resolution: "tmpl@npm:1.0.5" @@ -13637,12 +13629,12 @@ __metadata: languageName: node linkType: hard -"ts-api-utils@npm:^2.0.0": - version: 2.0.0 - resolution: "ts-api-utils@npm:2.0.0" +"ts-api-utils@npm:^2.1.0": + version: 2.1.0 + resolution: "ts-api-utils@npm:2.1.0" peerDependencies: typescript: ">=4.8.4" - checksum: 10/485bdf8bbba98d58712243d958f4fd44742bbe49e559cd77882fb426d866eec6dd05c67ef91935dc4f8a3c776f235859735e1f05be399e4dc9e7ffd580120974 + checksum: 10/02e55b49d9617c6eebf8aadfa08d3ca03ca0cd2f0586ad34117fdfc7aa3cd25d95051843fde9df86665ad907f99baed179e7a117b11021417f379e4d2614eacd languageName: node linkType: hard @@ -13842,17 +13834,18 @@ __metadata: languageName: node linkType: hard -"typescript-eslint@npm:^8.7.0": - version: 8.19.1 - resolution: "typescript-eslint@npm:8.19.1" +"typescript-eslint@npm:^8.48.0": + version: 8.48.0 + resolution: "typescript-eslint@npm:8.48.0" dependencies: - "@typescript-eslint/eslint-plugin": "npm:8.19.1" - "@typescript-eslint/parser": "npm:8.19.1" - "@typescript-eslint/utils": "npm:8.19.1" + "@typescript-eslint/eslint-plugin": "npm:8.48.0" + "@typescript-eslint/parser": "npm:8.48.0" + "@typescript-eslint/typescript-estree": "npm:8.48.0" + "@typescript-eslint/utils": "npm:8.48.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.8.0" - checksum: 10/3e7861bcd47c0bea962662d5f18a9f9214270057c082f2e3839ee2f681a42018395755216005d2408447de5b96892b6a18cc794daf8663bba1753def48e6756c + typescript: ">=4.8.4 <6.0.0" + checksum: 10/9be54df60faf3b5a6d255032b4478170b6f64e38b8396475a2049479d1e3c1f5a23a18bb4d2d6ff685ef92ff8f2af28215772fe33b48148a8cf83a724d0778d1 languageName: node linkType: hard