diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1554a37ec5e..fcfb57e5fde 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -26,6 +26,8 @@ /packages/transaction-controller @MetaMask/confirmations /packages/user-operation-controller @MetaMask/confirmations +## Earn Team +/packages/earn-controller @MetaMask/earn ## Notifications Team /packages/notification-services-controller @MetaMask/notifications @@ -35,12 +37,16 @@ ## Snaps Team /packages/rate-limit-controller @MetaMask/snaps-devs +## Swaps-Bridge Team +/packages/bridge-controller @MetaMask/swaps-engineers + ## Portfolio Team /packages/token-search-discovery-controller @MetaMask/portfolio ## Wallet API Platform Team /packages/multichain @MetaMask/wallet-api-platform-engineers /packages/queued-request-controller @MetaMask/wallet-api-platform-engineers +/packages/selected-network-controller @MetaMask/wallet-api-platform-engineers ## Wallet Framework Team /packages/base-controller @MetaMask/wallet-framework-engineers @@ -60,7 +66,6 @@ /packages/network-controller @MetaMask/wallet-framework-engineers @MetaMask/metamask-assets /packages/permission-controller @MetaMask/wallet-api-platform-engineers @MetaMask/wallet-framework-engineers @MetaMask/snaps-devs /packages/permission-log-controller @MetaMask/wallet-api-platform-engineers @MetaMask/wallet-framework-engineers -/packages/selected-network-controller @MetaMask/wallet-api-platform-engineers @MetaMask/wallet-framework-engineers @MetaMask/metamask-assets /packages/profile-sync-controller @MetaMask/notifications @MetaMask/identity ## Package Release related @@ -74,6 +79,8 @@ /packages/approval-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/wallet-framework-engineers /packages/assets-controllers/package.json @MetaMask/metamask-assets @MetaMask/wallet-framework-engineers /packages/assets-controllers/CHANGELOG.md @MetaMask/metamask-assets @MetaMask/wallet-framework-engineers +/packages/earn-controller/package.json @MetaMask/earn @MetaMask/wallet-framework-engineers +/packages/earn-controller/CHANGELOG.md @MetaMask/earn @MetaMask/wallet-framework-engineers /packages/ens-controller/package.json @MetaMask/confirmations @MetaMask/wallet-framework-engineers /packages/ens-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/wallet-framework-engineers /packages/gas-fee-controller/package.json @MetaMask/confirmations @MetaMask/wallet-framework-engineers @@ -108,3 +115,5 @@ /packages/multichain-transactions-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/wallet-framework-engineers /packages/token-search-discovery-controller/package.json @MetaMask/portfolio @MetaMask/wallet-framework-engineers /packages/token-search-discovery-controller/CHANGELOG.md @MetaMask/portfolio @MetaMask/wallet-framework-engineers +/packages/bridge-controller/package.json @MetaMask/swaps-engineers @MetaMask/wallet-framework-engineers +/packages/bridge-controller/CHANGELOG.md @MetaMask/swaps-engineers @MetaMask/wallet-framework-engineers \ No newline at end of file diff --git a/.github/workflows/create-update-issues.yaml b/.github/workflows/create-update-issues.yaml index 50243526aca..a24a3664b81 100644 --- a/.github/workflows/create-update-issues.yaml +++ b/.github/workflows/create-update-issues.yaml @@ -14,9 +14,8 @@ jobs: steps: - name: Checkout head uses: actions/checkout@v4 - with: - fetch-tags: true - + - name: Fetch tags + run: git fetch --prune --unshallow --tags - name: Create Issues env: GH_TOKEN: ${{ secrets.CORE_CREATE_UPDATE_ISSUES_TOKEN }} diff --git a/.gitignore b/.gitignore index 5043addaa41..6c1e52eb80d 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,4 @@ scripts/coverage !.yarn/versions # typescript -packages/*/*.tsbuildinfo +packages/*/*.tsbuildinfo \ No newline at end of file diff --git a/README.md b/README.md index 3035e645a09..d35222d2543 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,11 @@ Each package in this repository has its own README where you can find installati - [`@metamask/approval-controller`](packages/approval-controller) - [`@metamask/assets-controllers`](packages/assets-controllers) - [`@metamask/base-controller`](packages/base-controller) +- [`@metamask/bridge-controller`](packages/bridge-controller) - [`@metamask/build-utils`](packages/build-utils) - [`@metamask/composable-controller`](packages/composable-controller) - [`@metamask/controller-utils`](packages/controller-utils) +- [`@metamask/earn-controller`](packages/earn-controller) - [`@metamask/ens-controller`](packages/ens-controller) - [`@metamask/eth-json-rpc-provider`](packages/eth-json-rpc-provider) - [`@metamask/gas-fee-controller`](packages/gas-fee-controller) @@ -38,6 +40,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/logging-controller`](packages/logging-controller) - [`@metamask/message-manager`](packages/message-manager) - [`@metamask/multichain`](packages/multichain) +- [`@metamask/multichain-network-controller`](packages/multichain-network-controller) - [`@metamask/multichain-transactions-controller`](packages/multichain-transactions-controller) - [`@metamask/name-controller`](packages/name-controller) - [`@metamask/network-controller`](packages/network-controller) @@ -74,6 +77,7 @@ linkStyle default opacity:0.5 build_utils(["@metamask/build-utils"]); composable_controller(["@metamask/composable-controller"]); controller_utils(["@metamask/controller-utils"]); + earn_controller(["@metamask/earn-controller"]); ens_controller(["@metamask/ens-controller"]); eth_json_rpc_provider(["@metamask/eth-json-rpc-provider"]); gas_fee_controller(["@metamask/gas-fee-controller"]); @@ -83,6 +87,7 @@ linkStyle default opacity:0.5 logging_controller(["@metamask/logging-controller"]); message_manager(["@metamask/message-manager"]); multichain(["@metamask/multichain"]); + multichain_network_controller(["@metamask/multichain-network-controller"]); multichain_transactions_controller(["@metamask/multichain-transactions-controller"]); name_controller(["@metamask/name-controller"]); network_controller(["@metamask/network-controller"]); @@ -103,6 +108,7 @@ linkStyle default opacity:0.5 user_operation_controller(["@metamask/user-operation-controller"]); accounts_controller --> base_controller; accounts_controller --> keyring_controller; + accounts_controller --> network_controller; address_book_controller --> base_controller; address_book_controller --> controller_utils; announcement_controller --> base_controller; @@ -114,10 +120,15 @@ linkStyle default opacity:0.5 assets_controllers --> approval_controller; assets_controllers --> keyring_controller; assets_controllers --> network_controller; + assets_controllers --> permission_controller; assets_controllers --> preferences_controller; base_controller --> json_rpc_engine; composable_controller --> base_controller; composable_controller --> json_rpc_engine; + earn_controller --> base_controller; + earn_controller --> controller_utils; + earn_controller --> accounts_controller; + earn_controller --> network_controller; ens_controller --> base_controller; ens_controller --> controller_utils; ens_controller --> network_controller; @@ -134,8 +145,15 @@ linkStyle default opacity:0.5 message_manager --> base_controller; message_manager --> controller_utils; multichain --> controller_utils; + multichain --> json_rpc_engine; multichain --> network_controller; multichain --> permission_controller; + multichain_network_controller --> base_controller; + multichain_network_controller --> keyring_controller; + multichain_transactions_controller --> base_controller; + multichain_transactions_controller --> polling_controller; + multichain_transactions_controller --> accounts_controller; + multichain_transactions_controller --> keyring_controller; name_controller --> base_controller; name_controller --> controller_utils; network_controller --> base_controller; @@ -182,6 +200,7 @@ linkStyle default opacity:0.5 signature_controller --> keyring_controller; signature_controller --> logging_controller; signature_controller --> network_controller; + token_search_discovery_controller --> base_controller; transaction_controller --> base_controller; transaction_controller --> controller_utils; transaction_controller --> accounts_controller; diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index 3f81e2a510e..9a3e6c309d3 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -1,30 +1,816 @@ { - "@typescript-eslint/consistent-type-exports": 19, - "@typescript-eslint/no-base-to-string": 3, - "@typescript-eslint/no-duplicate-enum-values": 2, - "@typescript-eslint/no-unsafe-enum-comparison": 34, - "@typescript-eslint/no-unused-vars": 41, - "@typescript-eslint/prefer-promise-reject-errors": 33, - "@typescript-eslint/prefer-readonly": 147, - "@typescript-eslint/switch-exhaustiveness-check": 0, - "import-x/namespace": 189, - "import-x/no-named-as-default": 1, - "import-x/no-named-as-default-member": 8, - "import-x/order": 211, - "jest/no-conditional-in-test": 138, - "jest/prefer-lowercase-title": 2, - "jest/prefer-strict-equal": 2, - "jsdoc/check-tag-names": 375, - "jsdoc/require-returns": 25, - "jsdoc/tag-lines": 335, - "n/no-unsupported-features/node-builtins": 14, - "n/prefer-global/text-encoder": 4, - "n/prefer-global/text-decoder": 4, - "prettier/prettier": 116, - "promise/always-return": 3, - "promise/catch-or-return": 2, - "promise/param-names": 8, - "no-empty-function": 2, - "no-shadow": 8, - "no-unused-private-class-members": 5 + "examples/example-controllers/src/gas-prices-controller.test.ts": { + "import-x/order": 1 + }, + "examples/example-controllers/src/gas-prices-controller.ts": { + "@typescript-eslint/prefer-readonly": 1, + "prettier/prettier": 1 + }, + "examples/example-controllers/src/gas-prices-service/gas-prices-service.ts": { + "@typescript-eslint/prefer-readonly": 1, + "jsdoc/require-returns": 1 + }, + "examples/example-controllers/src/pet-names-controller.test.ts": { + "import-x/order": 2 + }, + "packages/accounts-controller/src/AccountsController.test.ts": { + "import-x/namespace": 1 + }, + "packages/address-book-controller/src/AddressBookController.ts": { + "jsdoc/check-tag-names": 13 + }, + "packages/approval-controller/src/ApprovalController.test.ts": { + "import-x/order": 1, + "jest/no-conditional-in-test": 16 + }, + "packages/approval-controller/src/ApprovalController.ts": { + "@typescript-eslint/prefer-readonly": 4 + }, + "packages/assets-controllers/jest.environment.js": { + "n/prefer-global/text-encoder": 1, + "n/prefer-global/text-decoder": 1, + "no-shadow": 2 + }, + "packages/assets-controllers/src/AccountTrackerController.test.ts": { + "import-x/namespace": 2 + }, + "packages/assets-controllers/src/AccountTrackerController.ts": { + "jsdoc/check-tag-names": 5, + "jsdoc/tag-lines": 1 + }, + "packages/assets-controllers/src/AssetsContractController.test.ts": { + "import-x/order": 3 + }, + "packages/assets-controllers/src/AssetsContractController.ts": { + "jsdoc/check-tag-names": 2, + "jsdoc/tag-lines": 1 + }, + "packages/assets-controllers/src/CurrencyRateController.test.ts": { + "import-x/order": 1, + "jest/no-conditional-in-test": 1 + }, + "packages/assets-controllers/src/CurrencyRateController.ts": { + "jsdoc/check-tag-names": 6 + }, + "packages/assets-controllers/src/NftController.test.ts": { + "import-x/namespace": 9, + "import-x/order": 3, + "jest/no-conditional-in-test": 8 + }, + "packages/assets-controllers/src/NftController.ts": { + "@typescript-eslint/prefer-readonly": 1, + "jsdoc/check-tag-names": 46, + "jsdoc/tag-lines": 4 + }, + "packages/assets-controllers/src/NftDetectionController.test.ts": { + "import-x/namespace": 6, + "import-x/order": 4 + }, + "packages/assets-controllers/src/NftDetectionController.ts": { + "jsdoc/check-tag-names": 34, + "jsdoc/tag-lines": 1 + }, + "packages/assets-controllers/src/RatesController/RatesController.test.ts": { + "import-x/order": 2, + "jsdoc/tag-lines": 4 + }, + "packages/assets-controllers/src/RatesController/RatesController.ts": { + "@typescript-eslint/prefer-readonly": 1, + "import-x/order": 1, + "jsdoc/tag-lines": 3 + }, + "packages/assets-controllers/src/RatesController/types.ts": { + "import-x/order": 1 + }, + "packages/assets-controllers/src/Standards/ERC20Standard.test.ts": { + "prettier/prettier": 1 + }, + "packages/assets-controllers/src/Standards/NftStandards/ERC721/ERC721Standard.ts": { + "prettier/prettier": 1 + }, + "packages/assets-controllers/src/TokenBalancesController.test.ts": { + "import-x/order": 1 + }, + "packages/assets-controllers/src/TokenBalancesController.ts": { + "@typescript-eslint/prefer-readonly": 4, + "jsdoc/check-tag-names": 4, + "jsdoc/tag-lines": 11 + }, + "packages/assets-controllers/src/TokenDetectionController.test.ts": { + "import-x/namespace": 11, + "import-x/order": 3, + "jsdoc/tag-lines": 1 + }, + "packages/assets-controllers/src/TokenDetectionController.ts": { + "@typescript-eslint/prefer-readonly": 3, + "jsdoc/check-tag-names": 8, + "jsdoc/tag-lines": 6, + "no-unused-private-class-members": 2 + }, + "packages/assets-controllers/src/TokenListController.test.ts": { + "import-x/namespace": 7, + "import-x/order": 3, + "jest/no-conditional-in-test": 2 + }, + "packages/assets-controllers/src/TokenListController.ts": { + "jsdoc/check-tag-names": 1, + "jsdoc/tag-lines": 7 + }, + "packages/assets-controllers/src/TokenRatesController.test.ts": { + "import-x/order": 3 + }, + "packages/assets-controllers/src/TokenRatesController.ts": { + "@typescript-eslint/prefer-readonly": 2, + "jsdoc/check-tag-names": 11, + "no-unused-private-class-members": 1 + }, + "packages/assets-controllers/src/TokensController.test.ts": { + "import-x/namespace": 1, + "import-x/order": 4, + "jest/no-conditional-in-test": 2 + }, + "packages/assets-controllers/src/TokensController.ts": { + "@typescript-eslint/no-unused-vars": 1, + "@typescript-eslint/prefer-readonly": 1, + "jsdoc/check-tag-names": 13, + "jsdoc/tag-lines": 3 + }, + "packages/assets-controllers/src/assetsUtil.test.ts": { + "jest/no-conditional-in-test": 2 + }, + "packages/assets-controllers/src/assetsUtil.ts": { + "jsdoc/tag-lines": 2 + }, + "packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts": { + "jsdoc/tag-lines": 2 + }, + "packages/assets-controllers/src/multicall.test.ts": { + "@typescript-eslint/prefer-promise-reject-errors": 2 + }, + "packages/assets-controllers/src/multicall.ts": { + "jsdoc/tag-lines": 1 + }, + "packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts": { + "jsdoc/require-returns": 1 + }, + "packages/assets-controllers/src/token-prices-service/codefi-v2.ts": { + "jsdoc/tag-lines": 2 + }, + "packages/base-controller/src/BaseControllerV2.test.ts": { + "import-x/namespace": 16 + }, + "packages/base-controller/src/BaseControllerV2.ts": { + "jsdoc/check-tag-names": 2 + }, + "packages/base-controller/src/Messenger.test.ts": { + "import-x/namespace": 33 + }, + "packages/base-controller/src/RestrictedMessenger.test.ts": { + "import-x/namespace": 31 + }, + "packages/build-utils/src/transforms/remove-fenced-code.test.ts": { + "import-x/order": 1 + }, + "packages/build-utils/src/transforms/remove-fenced-code.ts": { + "@typescript-eslint/no-unsafe-enum-comparison": 1 + }, + "packages/composable-controller/src/ComposableController.test.ts": { + "import-x/namespace": 3 + }, + "packages/composable-controller/src/ComposableController.ts": { + "@typescript-eslint/no-unused-vars": 1 + }, + "packages/controller-utils/jest.environment.js": { + "n/prefer-global/text-encoder": 1, + "n/prefer-global/text-decoder": 1, + "no-shadow": 2 + }, + "packages/controller-utils/src/siwe.ts": { + "@typescript-eslint/no-unused-vars": 1, + "jsdoc/check-tag-names": 5 + }, + "packages/controller-utils/src/types.ts": { + "@typescript-eslint/no-duplicate-enum-values": 2, + "jsdoc/tag-lines": 1 + }, + "packages/controller-utils/src/util.test.ts": { + "import-x/no-named-as-default": 1, + "import-x/order": 1, + "jest/no-conditional-in-test": 1, + "promise/param-names": 2 + }, + "packages/controller-utils/src/util.ts": { + "@typescript-eslint/no-base-to-string": 1, + "@typescript-eslint/no-unused-vars": 3, + "@typescript-eslint/prefer-promise-reject-errors": 1, + "promise/param-names": 3 + }, + "packages/ens-controller/src/EnsController.test.ts": { + "import-x/order": 2 + }, + "packages/ens-controller/src/EnsController.ts": { + "jsdoc/check-tag-names": 6 + }, + "packages/eth-json-rpc-provider/src/safe-event-emitter-provider.test.ts": { + "import-x/namespace": 1 + }, + "packages/eth-json-rpc-provider/src/safe-event-emitter-provider.ts": { + "@typescript-eslint/prefer-readonly": 1 + }, + "packages/gas-fee-controller/src/GasFeeController.test.ts": { + "import-x/namespace": 2, + "import-x/order": 1 + }, + "packages/gas-fee-controller/src/GasFeeController.ts": { + "@typescript-eslint/prefer-readonly": 1, + "jsdoc/check-tag-names": 21 + }, + "packages/gas-fee-controller/src/determineGasFeeCalculations.ts": { + "jsdoc/tag-lines": 4 + }, + "packages/json-rpc-engine/src/JsonRpcEngine.test.ts": { + "jest/no-conditional-in-test": 2 + }, + "packages/json-rpc-engine/src/JsonRpcEngine.ts": { + "@typescript-eslint/prefer-promise-reject-errors": 2 + }, + "packages/json-rpc-middleware-stream/src/index.test.ts": { + "@typescript-eslint/prefer-promise-reject-errors": 3, + "no-empty-function": 1 + }, + "packages/keyring-controller/jest.environment.js": { + "n/no-unsupported-features/node-builtins": 1 + }, + "packages/keyring-controller/src/KeyringController.test.ts": { + "import-x/namespace": 16, + "jest/no-conditional-in-test": 8 + }, + "packages/keyring-controller/src/KeyringController.ts": { + "@typescript-eslint/no-unsafe-enum-comparison": 5, + "@typescript-eslint/no-unused-vars": 2, + "jsdoc/tag-lines": 1 + }, + "packages/keyring-controller/tests/mocks/mockKeyring.ts": { + "@typescript-eslint/prefer-readonly": 1 + }, + "packages/logging-controller/src/LoggingController.test.ts": { + "import-x/namespace": 1 + }, + "packages/logging-controller/src/LoggingController.ts": { + "jsdoc/check-tag-names": 1 + }, + "packages/logging-controller/src/logTypes/index.ts": { + "@typescript-eslint/consistent-type-exports": 1 + }, + "packages/message-manager/src/AbstractMessageManager.test.ts": { + "jest/no-conditional-in-test": 7 + }, + "packages/message-manager/src/AbstractMessageManager.ts": { + "jsdoc/check-tag-names": 25, + "jsdoc/tag-lines": 2 + }, + "packages/message-manager/src/DecryptMessageManager.test.ts": { + "jest/no-conditional-in-test": 3 + }, + "packages/message-manager/src/DecryptMessageManager.ts": { + "jsdoc/check-tag-names": 11 + }, + "packages/message-manager/src/EncryptionPublicKeyManager.test.ts": { + "jest/no-conditional-in-test": 5 + }, + "packages/message-manager/src/EncryptionPublicKeyManager.ts": { + "jsdoc/check-tag-names": 13 + }, + "packages/message-manager/src/index.ts": { + "@typescript-eslint/consistent-type-exports": 1 + }, + "packages/message-manager/src/utils.ts": { + "@typescript-eslint/no-unused-vars": 1 + }, + "packages/multichain/src/adapters/caip-permission-adapter-eth-accounts.test.ts": { + "import-x/order": 1 + }, + "packages/multichain/src/adapters/caip-permission-adapter-eth-accounts.ts": { + "@typescript-eslint/no-unsafe-enum-comparison": 1, + "jsdoc/tag-lines": 5 + }, + "packages/multichain/src/adapters/caip-permission-adapter-permittedChains.test.ts": { + "import-x/order": 1 + }, + "packages/multichain/src/adapters/caip-permission-adapter-permittedChains.ts": { + "jsdoc/tag-lines": 5 + }, + "packages/multichain/src/adapters/caip-permission-adapter-session-scopes.test.ts": { + "import-x/order": 1 + }, + "packages/multichain/src/adapters/caip-permission-adapter-session-scopes.ts": { + "jsdoc/tag-lines": 3 + }, + "packages/multichain/src/caip25Permission.test.ts": { + "@typescript-eslint/no-unused-vars": 3 + }, + "packages/multichain/src/caip25Permission.ts": { + "@typescript-eslint/no-unused-vars": 1, + "jsdoc/tag-lines": 1 + }, + "packages/multichain/src/handlers/wallet-getSession.test.ts": { + "import-x/order": 1 + }, + "packages/multichain/src/handlers/wallet-getSession.ts": { + "@typescript-eslint/no-unused-vars": 2, + "jsdoc/require-returns": 1 + }, + "packages/multichain/src/handlers/wallet-invokeMethod.test.ts": { + "import-x/order": 2 + }, + "packages/multichain/src/handlers/wallet-invokeMethod.ts": { + "@typescript-eslint/no-unused-vars": 1, + "jsdoc/require-returns": 1 + }, + "packages/multichain/src/handlers/wallet-revokeSession.test.ts": { + "import-x/order": 1, + "prettier/prettier": 2 + }, + "packages/multichain/src/handlers/wallet-revokeSession.ts": { + "@typescript-eslint/no-unused-vars": 1, + "jsdoc/require-returns": 1 + }, + "packages/multichain/src/middlewares/MultichainMiddlewareManager.test.ts": { + "prettier/prettier": 1 + }, + "packages/multichain/src/middlewares/MultichainSubscriptionManager.ts": { + "@typescript-eslint/prefer-readonly": 2, + "import-x/order": 1 + }, + "packages/multichain/src/middlewares/multichainMethodCallValidator.test.ts": { + "@typescript-eslint/prefer-promise-reject-errors": 20 + }, + "packages/multichain/src/scope/assert.test.ts": { + "@typescript-eslint/no-unused-vars": 3 + }, + "packages/multichain/src/scope/assert.ts": { + "@typescript-eslint/no-unsafe-enum-comparison": 1, + "jsdoc/tag-lines": 8 + }, + "packages/multichain/src/scope/authorization.ts": { + "jsdoc/tag-lines": 2 + }, + "packages/multichain/src/scope/errors.ts": { + "jsdoc/tag-lines": 5 + }, + "packages/multichain/src/scope/filter.test.ts": { + "jest/no-conditional-in-test": 9, + "prettier/prettier": 1 + }, + "packages/multichain/src/scope/filter.ts": { + "@typescript-eslint/no-unused-vars": 1, + "jsdoc/tag-lines": 3 + }, + "packages/multichain/src/scope/supported.ts": { + "@typescript-eslint/no-unsafe-enum-comparison": 4, + "jsdoc/tag-lines": 4 + }, + "packages/multichain/src/scope/transform.ts": { + "jsdoc/tag-lines": 3 + }, + "packages/multichain/src/scope/types.ts": { + "jsdoc/tag-lines": 1 + }, + "packages/multichain/src/scope/validation.ts": { + "jsdoc/tag-lines": 2 + }, + "packages/name-controller/src/NameController.ts": { + "@typescript-eslint/no-unsafe-enum-comparison": 1, + "@typescript-eslint/prefer-readonly": 2 + }, + "packages/name-controller/src/providers/ens.test.ts": { + "import-x/order": 1 + }, + "packages/name-controller/src/providers/ens.ts": { + "@typescript-eslint/prefer-readonly": 2 + }, + "packages/name-controller/src/providers/etherscan.test.ts": { + "import-x/order": 1 + }, + "packages/name-controller/src/providers/etherscan.ts": { + "@typescript-eslint/prefer-readonly": 2 + }, + "packages/name-controller/src/providers/lens.test.ts": { + "import-x/order": 1 + }, + "packages/name-controller/src/providers/lens.ts": { + "@typescript-eslint/prefer-readonly": 1 + }, + "packages/name-controller/src/providers/token.test.ts": { + "import-x/order": 1 + }, + "packages/name-controller/src/providers/token.ts": { + "@typescript-eslint/prefer-readonly": 1 + }, + "packages/name-controller/src/util.ts": { + "jsdoc/require-returns": 1 + }, + "packages/network-controller/src/NetworkController.ts": { + "@typescript-eslint/prefer-promise-reject-errors": 1, + "@typescript-eslint/prefer-readonly": 2, + "jsdoc/tag-lines": 1, + "prettier/prettier": 1 + }, + "packages/network-controller/tests/NetworkController.test.ts": { + "@typescript-eslint/no-unused-vars": 1, + "@typescript-eslint/prefer-promise-reject-errors": 1, + "import-x/order": 1 + }, + "packages/network-controller/tests/create-network-client.test.ts": { + "import-x/order": 1 + }, + "packages/network-controller/tests/provider-api-tests/helpers.ts": { + "@typescript-eslint/prefer-promise-reject-errors": 1, + "import-x/namespace": 1, + "import-x/no-named-as-default-member": 1, + "promise/catch-or-return": 1 + }, + "packages/permission-controller/src/Permission.ts": { + "prettier/prettier": 11 + }, + "packages/permission-controller/src/PermissionController.test.ts": { + "jest/no-conditional-in-test": 4 + }, + "packages/permission-controller/src/PermissionController.ts": { + "prettier/prettier": 12 + }, + "packages/permission-controller/src/rpc-methods/getPermissions.test.ts": { + "import-x/order": 1 + }, + "packages/permission-controller/src/rpc-methods/requestPermissions.ts": { + "prettier/prettier": 1 + }, + "packages/permission-log-controller/src/PermissionLogController.ts": { + "@typescript-eslint/prefer-readonly": 1, + "jsdoc/check-tag-names": 2, + "jsdoc/tag-lines": 1 + }, + "packages/permission-log-controller/tests/PermissionLogController.test.ts": { + "import-x/order": 1 + }, + "packages/phishing-controller/src/PhishingController.test.ts": { + "import-x/namespace": 36, + "import-x/no-named-as-default-member": 1, + "jsdoc/tag-lines": 1 + }, + "packages/phishing-controller/src/PhishingController.ts": { + "jsdoc/check-tag-names": 42, + "jsdoc/tag-lines": 1 + }, + "packages/phishing-controller/src/PhishingDetector.ts": { + "@typescript-eslint/no-unused-vars": 1, + "@typescript-eslint/prefer-readonly": 2, + "jsdoc/tag-lines": 2 + }, + "packages/phishing-controller/src/tests/utils.ts": { + "@typescript-eslint/no-unused-vars": 1 + }, + "packages/phishing-controller/src/utils.test.ts": { + "import-x/namespace": 5 + }, + "packages/phishing-controller/src/utils.ts": { + "@typescript-eslint/no-unsafe-enum-comparison": 1, + "@typescript-eslint/no-unused-vars": 2 + }, + "packages/polling-controller/src/AbstractPollingController.ts": { + "@typescript-eslint/prefer-readonly": 1 + }, + "packages/preferences-controller/src/PreferencesController.test.ts": { + "prettier/prettier": 4 + }, + "packages/queued-request-controller/src/QueuedRequestController.ts": { + "@typescript-eslint/prefer-readonly": 2 + }, + "packages/rate-limit-controller/src/RateLimitController.ts": { + "jsdoc/check-tag-names": 4, + "jsdoc/require-returns": 1, + "jsdoc/tag-lines": 3 + }, + "packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts": { + "import-x/order": 1, + "jsdoc/tag-lines": 1, + "promise/param-names": 1 + }, + "packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts": { + "@typescript-eslint/prefer-readonly": 4, + "jsdoc/tag-lines": 2 + }, + "packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts": { + "@typescript-eslint/prefer-readonly": 1, + "jsdoc/check-tag-names": 2, + "prettier/prettier": 1 + }, + "packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts": { + "jest/no-conditional-in-test": 1, + "prettier/prettier": 2 + }, + "packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts": { + "jsdoc/tag-lines": 2 + }, + "packages/selected-network-controller/src/SelectedNetworkController.ts": { + "@typescript-eslint/prefer-readonly": 1, + "prettier/prettier": 6 + }, + "packages/selected-network-controller/tests/SelectedNetworkController.test.ts": { + "jest/no-conditional-in-test": 1 + }, + "packages/signature-controller/src/SignatureController.test.ts": { + "import-x/order": 1, + "jsdoc/tag-lines": 3 + }, + "packages/signature-controller/src/SignatureController.ts": { + "@typescript-eslint/no-unsafe-enum-comparison": 4, + "@typescript-eslint/prefer-readonly": 3, + "jsdoc/tag-lines": 8 + }, + "packages/signature-controller/src/utils/decoding-api.test.ts": { + "import-x/order": 1, + "jsdoc/tag-lines": 1 + }, + "packages/signature-controller/src/utils/decoding-api.ts": { + "import-x/order": 1 + }, + "packages/signature-controller/src/utils/normalize.test.ts": { + "import-x/order": 1 + }, + "packages/signature-controller/src/utils/normalize.ts": { + "@typescript-eslint/no-unused-vars": 1, + "jsdoc/tag-lines": 2 + }, + "packages/signature-controller/src/utils/validation.test.ts": { + "import-x/order": 1 + }, + "packages/signature-controller/src/utils/validation.ts": { + "@typescript-eslint/no-base-to-string": 1, + "@typescript-eslint/no-unused-vars": 2, + "jsdoc/tag-lines": 4 + }, + "packages/transaction-controller/src/TransactionController.test.ts": { + "import-x/namespace": 1, + "import-x/order": 4, + "jsdoc/tag-lines": 1, + "promise/always-return": 2 + }, + "packages/transaction-controller/src/TransactionController.ts": { + "jsdoc/check-tag-names": 35, + "jsdoc/require-returns": 5 + }, + "packages/transaction-controller/src/TransactionControllerIntegration.test.ts": { + "import-x/order": 4, + "jsdoc/tag-lines": 1 + }, + "packages/transaction-controller/src/api/accounts-api.test.ts": { + "import-x/order": 1, + "jsdoc/tag-lines": 1 + }, + "packages/transaction-controller/src/api/accounts-api.ts": { + "jsdoc/tag-lines": 2 + }, + "packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.test.ts": { + "import-x/order": 1 + }, + "packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts": { + "import-x/order": 2 + }, + "packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts": { + "import-x/order": 1 + }, + "packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.test.ts": { + "import-x/order": 1 + }, + "packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.ts": { + "import-x/order": 1 + }, + "packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts": { + "import-x/order": 1, + "jsdoc/tag-lines": 1 + }, + "packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.test.ts": { + "import-x/order": 1 + }, + "packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.ts": { + "import-x/order": 1 + }, + "packages/transaction-controller/src/gas-flows/TestGasFeeFlow.test.ts": { + "import-x/order": 1 + }, + "packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.test.ts": { + "import-x/order": 1 + }, + "packages/transaction-controller/src/helpers/GasFeePoller.test.ts": { + "import-x/order": 1, + "jsdoc/tag-lines": 1, + "prettier/prettier": 1 + }, + "packages/transaction-controller/src/helpers/GasFeePoller.ts": { + "@typescript-eslint/prefer-readonly": 6, + "jsdoc/tag-lines": 1 + }, + "packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts": { + "import-x/order": 1, + "jsdoc/tag-lines": 1 + }, + "packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts": { + "@typescript-eslint/prefer-readonly": 11 + }, + "packages/transaction-controller/src/helpers/MethodDataHelper.test.ts": { + "import-x/order": 1, + "jsdoc/tag-lines": 1 + }, + "packages/transaction-controller/src/helpers/MethodDataHelper.ts": { + "@typescript-eslint/prefer-readonly": 4 + }, + "packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts": { + "import-x/order": 1, + "jsdoc/tag-lines": 2 + }, + "packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts": { + "@typescript-eslint/no-unused-vars": 2, + "@typescript-eslint/prefer-readonly": 1, + "no-unused-private-class-members": 1 + }, + "packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts": { + "jsdoc/tag-lines": 3 + }, + "packages/transaction-controller/src/helpers/PendingTransactionTracker.ts": { + "@typescript-eslint/prefer-readonly": 12 + }, + "packages/transaction-controller/src/helpers/TransactionPoller.test.ts": { + "import-x/order": 1, + "jsdoc/tag-lines": 1 + }, + "packages/transaction-controller/src/helpers/TransactionPoller.ts": { + "@typescript-eslint/prefer-readonly": 1, + "jsdoc/tag-lines": 2 + }, + "packages/transaction-controller/src/utils/external-transactions.test.ts": { + "import-x/order": 1 + }, + "packages/transaction-controller/src/utils/gas-fees.test.ts": { + "import-x/order": 2, + "jsdoc/tag-lines": 1 + }, + "packages/transaction-controller/src/utils/gas-fees.ts": { + "import-x/order": 2 + }, + "packages/transaction-controller/src/utils/gas-flow.test.ts": { + "import-x/order": 1, + "jsdoc/tag-lines": 1 + }, + "packages/transaction-controller/src/utils/gas-flow.ts": { + "jsdoc/tag-lines": 4 + }, + "packages/transaction-controller/src/utils/gas.test.ts": { + "import-x/order": 2, + "jsdoc/tag-lines": 2 + }, + "packages/transaction-controller/src/utils/gas.ts": { + "prettier/prettier": 1 + }, + "packages/transaction-controller/src/utils/history.test.ts": { + "import-x/order": 1 + }, + "packages/transaction-controller/src/utils/layer1-gas-fee-flow.test.ts": { + "import-x/order": 1, + "jsdoc/tag-lines": 1 + }, + "packages/transaction-controller/src/utils/layer1-gas-fee-flow.ts": { + "jsdoc/require-returns": 1, + "jsdoc/tag-lines": 3 + }, + "packages/transaction-controller/src/utils/nonce.test.ts": { + "import-x/order": 1 + }, + "packages/transaction-controller/src/utils/retry.test.ts": { + "import-x/order": 1 + }, + "packages/transaction-controller/src/utils/retry.ts": { + "jsdoc/tag-lines": 4 + }, + "packages/transaction-controller/src/utils/simulation-api.test.ts": { + "@typescript-eslint/no-base-to-string": 1, + "import-x/order": 1, + "jest/no-conditional-in-test": 1, + "jsdoc/tag-lines": 1 + }, + "packages/transaction-controller/src/utils/simulation-api.ts": { + "jsdoc/require-returns": 2, + "jsdoc/tag-lines": 3 + }, + "packages/transaction-controller/src/utils/simulation.test.ts": { + "import-x/order": 2, + "jsdoc/tag-lines": 5 + }, + "packages/transaction-controller/src/utils/simulation.ts": { + "@typescript-eslint/no-unused-vars": 1, + "import-x/order": 2, + "jsdoc/tag-lines": 16 + }, + "packages/transaction-controller/src/utils/swaps.test.ts": { + "import-x/order": 1, + "promise/always-return": 1, + "promise/catch-or-return": 1 + }, + "packages/transaction-controller/src/utils/swaps.ts": { + "import-x/order": 1, + "jsdoc/require-returns": 1 + }, + "packages/transaction-controller/src/utils/transaction-type.test.ts": { + "import-x/order": 1 + }, + "packages/transaction-controller/src/utils/transaction-type.ts": { + "@typescript-eslint/no-unused-vars": 1 + }, + "packages/transaction-controller/src/utils/utils.test.ts": { + "import-x/order": 1 + }, + "packages/user-operation-controller/src/UserOperationController.test.ts": { + "jsdoc/tag-lines": 4 + }, + "packages/user-operation-controller/src/UserOperationController.ts": { + "@typescript-eslint/prefer-promise-reject-errors": 1, + "@typescript-eslint/prefer-readonly": 3, + "jsdoc/require-returns": 2 + }, + "packages/user-operation-controller/src/helpers/Bundler.test.ts": { + "import-x/order": 1, + "jsdoc/require-returns": 1, + "jsdoc/tag-lines": 1 + }, + "packages/user-operation-controller/src/helpers/Bundler.ts": { + "@typescript-eslint/prefer-readonly": 1, + "jsdoc/tag-lines": 2 + }, + "packages/user-operation-controller/src/helpers/PendingUserOperationTracker.test.ts": { + "import-x/order": 2, + "jsdoc/tag-lines": 4, + "prettier/prettier": 1 + }, + "packages/user-operation-controller/src/helpers/PendingUserOperationTracker.ts": { + "@typescript-eslint/prefer-readonly": 2, + "import-x/order": 1 + }, + "packages/user-operation-controller/src/helpers/SnapSmartContractAccount.test.ts": { + "import-x/order": 1, + "jsdoc/tag-lines": 1 + }, + "packages/user-operation-controller/src/helpers/SnapSmartContractAccount.ts": { + "@typescript-eslint/prefer-readonly": 1 + }, + "packages/user-operation-controller/src/types.ts": { + "jsdoc/tag-lines": 3 + }, + "packages/user-operation-controller/src/utils/gas-fees.ts": { + "jsdoc/tag-lines": 7 + }, + "packages/user-operation-controller/src/utils/gas.test.ts": { + "import-x/order": 1, + "jsdoc/tag-lines": 1 + }, + "packages/user-operation-controller/src/utils/gas.ts": { + "jsdoc/tag-lines": 2 + }, + "packages/user-operation-controller/src/utils/transaction.test.ts": { + "import-x/order": 1 + }, + "packages/user-operation-controller/src/utils/transaction.ts": { + "jsdoc/tag-lines": 2 + }, + "packages/user-operation-controller/src/utils/validation.test.ts": { + "import-x/order": 1, + "jsdoc/tag-lines": 2 + }, + "packages/user-operation-controller/src/utils/validation.ts": { + "jsdoc/tag-lines": 8 + }, + "scripts/create-package/utils.test.ts": { + "@typescript-eslint/no-unsafe-enum-comparison": 3, + "import-x/no-named-as-default-member": 2, + "jest/no-conditional-in-test": 1 + }, + "scripts/create-package/utils.ts": { + "@typescript-eslint/no-unsafe-enum-comparison": 5, + "prettier/prettier": 1 + }, + "tests/fake-block-tracker.ts": { + "no-empty-function": 1 + }, + "tests/fake-provider.ts": { + "@typescript-eslint/prefer-promise-reject-errors": 1, + "@typescript-eslint/prefer-readonly": 2, + "jsdoc/check-tag-names": 12 + }, + "tests/mock-network.ts": { + "@typescript-eslint/no-unsafe-enum-comparison": 1, + "@typescript-eslint/prefer-readonly": 3, + "jsdoc/check-tag-names": 10 + }, + "tests/setupAfterEnv/nock.ts": { + "import-x/no-named-as-default-member": 3 + } } diff --git a/examples/example-controllers/package.json b/examples/example-controllers/package.json index c4322bb6860..da75b2b8f52 100644 --- a/examples/example-controllers/package.json +++ b/examples/example-controllers/package.json @@ -47,12 +47,12 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/utils": "^11.0.1" + "@metamask/base-controller": "^8.0.0", + "@metamask/utils": "^11.1.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/controller-utils": "^11.4.5", + "@metamask/controller-utils": "^11.5.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/jest.config.packages.js b/jest.config.packages.js index 96bde23bfc7..98abf47be2b 100644 --- a/jest.config.packages.js +++ b/jest.config.packages.js @@ -100,6 +100,10 @@ module.exports = { // A preset that is used as a base for Jest's configuration preset: 'ts-jest', + // The path to the Prettier executable used to format snapshots + // Jest doesn't support Prettier 3 yet, so we use Prettier 2 + prettierPath: require.resolve('prettier-2'), + // Run tests from one or more projects // projects: undefined diff --git a/jest.config.scripts.js b/jest.config.scripts.js index 343c51b9d2c..cb984e727e2 100644 --- a/jest.config.scripts.js +++ b/jest.config.scripts.js @@ -50,6 +50,10 @@ module.exports = { // // A preset that is used as a base for Jest's configuration // preset: 'ts-jest', + // The path to the Prettier executable used to format snapshots + // Jest doesn't support Prettier 3 yet, so we use Prettier 2 + prettierPath: require.resolve('prettier-2'), + // "resetMocks" resets all mocks, including mocked modules, to jest.fn(), // between each test case. resetMocks: true, diff --git a/package.json b/package.json index 11a482efd8e..00fbc57d3ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "287.0.0", + "version": "299.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { @@ -21,11 +21,11 @@ "changelog:update": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run changelog:update", "changelog:validate": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run changelog:validate", "create-package": "ts-node scripts/create-package", - "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies && yarn lint:teams", + "lint": "yarn lint:eslint && echo && yarn lint:misc --check && yarn constraints && yarn lint:dependencies && yarn lint:teams", "lint:dependencies": "depcheck && yarn dedupe --check", "lint:dependencies:fix": "depcheck && yarn dedupe", "lint:eslint": "yarn build:only-clean && yarn ts-node ./scripts/run-eslint.ts --cache", - "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies:fix", + "lint:fix": "yarn lint:eslint --fix && echo && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies:fix", "lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path .gitignore", "lint:teams": "ts-node scripts/lint-teams-json.ts", "prepack": "./scripts/prepack.sh", @@ -60,9 +60,9 @@ "@metamask/eslint-config-nodejs": "^14.0.0", "@metamask/eslint-config-typescript": "^14.0.0", "@metamask/eth-block-tracker": "^11.0.3", - "@metamask/eth-json-rpc-provider": "^4.1.7", - "@metamask/json-rpc-engine": "^10.0.2", - "@metamask/utils": "^11.0.1", + "@metamask/eth-json-rpc-provider": "^4.1.8", + "@metamask/json-rpc-engine": "^10.0.3", + "@metamask/utils": "^11.1.0", "@ts-bridge/cli": "^0.6.1", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.191", @@ -75,6 +75,7 @@ "@yarnpkg/fslib": "^3.1.1", "@yarnpkg/types": "^4.0.0", "babel-jest": "^29.7.0", + "chalk": "^4.1.2", "depcheck": "^1.4.7", "eslint": "^9.11.0", "eslint-config-prettier": "^9.1.0", @@ -92,6 +93,7 @@ "lodash": "^4.17.21", "nock": "^13.3.1", "prettier": "^3.3.3", + "prettier-2": "npm:prettier@^2.8.8", "prettier-plugin-packagejson": "^2.4.5", "rimraf": "^5.0.5", "semver": "^7.6.3", diff --git a/packages/accounts-controller/CHANGELOG.md b/packages/accounts-controller/CHANGELOG.md index afb00ca36a9..63cffecab42 100644 --- a/packages/accounts-controller/CHANGELOG.md +++ b/packages/accounts-controller/CHANGELOG.md @@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [24.0.0] + +### Added + +- **BREAKING:** Now requires `MultichainNetworkController:didNetworkChange` event to be registered on the messenger ([#5215](https://github.com/MetaMask/core/pull/5215)) + - This will be used to keep accounts in sync with EVM and non-EVM network changes. + +### Changed + +- **BREAKING:** Add `@metamask/network-controller@^22.0.0` peer dependency ([#5215](https://github.com/MetaMask/core/pull/5215)), ([#5327](https://github.com/MetaMask/core/pull/5327)) + +## [23.1.0] + +### Added + +- Add new keyring type for OneKey ([#5216](https://github.com/MetaMask/core/pull/5216)) + +## [23.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) + +## [23.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/snaps-controllers` peer dependency from `^9.7.0` to `^9.19.0` ([#5265](https://github.com/MetaMask/core/pull/5265)) +- Bump `@metamask/keyring-api"` from `^16.1.0` to `^17.0.0` ([#5280](https://github.com/MetaMask/core/pull/5280)) +- Bump `@metamask/eth-snap-keyring` from `^9.1.1` to `^10.0.0` ([#5280](https://github.com/MetaMask/core/pull/5280)) +- Bump `@metamask/snaps-sdk` from `^6.7.0` to `^6.17.1` ([#5220](https://github.com/MetaMask/core/pull/5220)), ([#5265](https://github.com/MetaMask/core/pull/5265)) +- Bump `@metamask/snaps-utils` from `^8.9.0` to `^8.10.0` ([#5265](https://github.com/MetaMask/core/pull/5265)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + +### Fixed + +- Properly exports public members ([#5224](https://github.com/MetaMask/core/pull/5224)) + - The new events (`AccountsController:account{AssetList,Balances,Transactions}Updated`) from the previous versions but were not exported. + +## [22.0.0] + +### Added + +- Add `AccountsController:account{AssetList,Balances,Transactions}Updated` events ([#5190](https://github.com/MetaMask/core/pull/5190)) + - Those events are being sent from Account Snaps (through the Snap keyring) and are being re-published by the `AccountController`. + +### Changed + +- **BREAKING:** Now requires `SnapKeyring:account{AssetList,Balances,Transactions}Updated` events to be registered on the messenger ([#5190](https://github.com/MetaMask/core/pull/5190)) +- Bump `@metamask/keyring-api` from `^14.0.0` to `^16.1.0` ([#5190](https://github.com/MetaMask/core/pull/5190)), ([#5208](https://github.com/MetaMask/core/pull/5208)) +- Bump `@metamask/keyring-internal-api` from `^2.0.1` to `^4.0.1` ([#5190](https://github.com/MetaMask/core/pull/5190)), ([#5208](https://github.com/MetaMask/core/pull/5208)) +- Bump `@metamask/eth-snap-keyring` from `^8.1.1` to `^9.1.1` ([#5190](https://github.com/MetaMask/core/pull/5190)), ([#5208](https://github.com/MetaMask/core/pull/5208)) + ## [21.0.2] ### Changed @@ -402,7 +455,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#1637](https://github.com/MetaMask/core/pull/1637)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@21.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@24.0.0...HEAD +[24.0.0]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@23.1.0...@metamask/accounts-controller@24.0.0 +[23.1.0]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@23.0.1...@metamask/accounts-controller@23.1.0 +[23.0.1]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@23.0.0...@metamask/accounts-controller@23.0.1 +[23.0.0]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@22.0.0...@metamask/accounts-controller@23.0.0 +[22.0.0]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@21.0.2...@metamask/accounts-controller@22.0.0 [21.0.2]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@21.0.1...@metamask/accounts-controller@21.0.2 [21.0.1]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@21.0.0...@metamask/accounts-controller@21.0.1 [21.0.0]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@20.0.2...@metamask/accounts-controller@21.0.0 diff --git a/packages/accounts-controller/package.json b/packages/accounts-controller/package.json index cc60092e2e0..2b7de49c311 100644 --- a/packages/accounts-controller/package.json +++ b/packages/accounts-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/accounts-controller", - "version": "21.0.2", + "version": "24.0.0", "description": "Manages internal accounts", "keywords": [ "MetaMask", @@ -48,13 +48,14 @@ }, "dependencies": { "@ethereumjs/util": "^8.1.0", - "@metamask/base-controller": "^7.1.1", - "@metamask/eth-snap-keyring": "^8.1.1", - "@metamask/keyring-api": "^14.0.0", - "@metamask/keyring-internal-api": "^2.0.1", - "@metamask/snaps-sdk": "^6.7.0", - "@metamask/snaps-utils": "^8.3.0", - "@metamask/utils": "^11.0.1", + "@metamask/base-controller": "^8.0.0", + "@metamask/eth-snap-keyring": "^10.0.0", + "@metamask/keyring-api": "^17.0.0", + "@metamask/keyring-internal-api": "^4.0.1", + "@metamask/network-controller": "^22.2.1", + "@metamask/snaps-sdk": "^6.17.1", + "@metamask/snaps-utils": "^8.10.0", + "@metamask/utils": "^11.1.0", "deepmerge": "^4.2.2", "ethereum-cryptography": "^2.1.2", "immer": "^9.0.6", @@ -62,9 +63,9 @@ }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^19.0.4", + "@metamask/keyring-controller": "^19.1.0", "@metamask/providers": "^18.1.1", - "@metamask/snaps-controllers": "^9.10.0", + "@metamask/snaps-controllers": "^9.19.0", "@types/jest": "^27.4.1", "@types/readable-stream": "^2.3.0", "jest": "^27.5.1", @@ -76,8 +77,9 @@ }, "peerDependencies": { "@metamask/keyring-controller": "^19.0.0", + "@metamask/network-controller": "^22.0.0", "@metamask/providers": "^18.1.0", - "@metamask/snaps-controllers": "^9.7.0", + "@metamask/snaps-controllers": "^9.19.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 8c612d1e18c..ad80a09febf 100644 --- a/packages/accounts-controller/src/AccountsController.test.ts +++ b/packages/accounts-controller/src/AccountsController.test.ts @@ -1,17 +1,24 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; +import { InfuraNetworkType } from '@metamask/controller-utils'; +import type { + AccountAssetListUpdatedEventPayload, + AccountBalancesUpdatedEventPayload, + AccountTransactionsUpdatedEventPayload, +} from '@metamask/keyring-api'; import { BtcAccountType, EthAccountType, BtcMethod, EthMethod, - EthScopes, - BtcScopes, + EthScope, + BtcScope, } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount, InternalAccountType, } from '@metamask/keyring-internal-api'; +import type { NetworkClientId } from '@metamask/network-controller'; import type { SnapControllerState } from '@metamask/snaps-controllers'; import { SnapStatus } from '@metamask/snaps-utils'; import type { CaipChainId } from '@metamask/utils'; @@ -68,7 +75,7 @@ const mockAccount: InternalAccount = { options: {}, methods: [...ETH_EOA_METHODS], type: EthAccountType.Eoa, - scopes: [EthScopes.Namespace], + scopes: [EthScope.Eoa], metadata: { name: 'Account 1', keyring: { type: KeyringTypes.hd }, @@ -84,7 +91,7 @@ const mockAccount2: InternalAccount = { options: {}, methods: [...ETH_EOA_METHODS], type: EthAccountType.Eoa, - scopes: [EthScopes.Namespace], + scopes: [EthScope.Eoa], metadata: { name: 'Account 2', keyring: { type: KeyringTypes.hd }, @@ -99,7 +106,7 @@ const mockAccount3: InternalAccount = { options: {}, methods: [...ETH_EOA_METHODS], type: EthAccountType.Eoa, - scopes: [EthScopes.Namespace], + scopes: [EthScope.Eoa], metadata: { name: '', keyring: { type: KeyringTypes.snap }, @@ -119,7 +126,7 @@ const mockAccount4: InternalAccount = { options: {}, methods: [...ETH_EOA_METHODS], type: EthAccountType.Eoa, - scopes: [EthScopes.Namespace], + scopes: [EthScope.Eoa], metadata: { name: 'Custom Name', keyring: { type: KeyringTypes.snap }, @@ -134,7 +141,7 @@ const mockAccount4: InternalAccount = { }; class MockNormalAccountUUID { - #accountIds: Record = {}; + readonly #accountIds: Record = {}; constructor(accounts: InternalAccount[]) { for (const account of accounts) { @@ -208,19 +215,19 @@ function createExpectedInternalAccount({ }): InternalAccount { const accountTypeToInfo: Record< string, - { methods: string[]; scopes: string[] } + { methods: string[]; scopes: CaipChainId[] } > = { [`${EthAccountType.Eoa}`]: { methods: [...Object.values(ETH_EOA_METHODS)], - scopes: [EthScopes.Namespace], + scopes: [EthScope.Eoa], }, [`${EthAccountType.Erc4337}`]: { methods: [...Object.values(ETH_ERC_4337_METHODS)], - scopes: [EthScopes.Mainnet], // Assuming we are using mainnet for those Smart Accounts + scopes: [EthScope.Mainnet], // Assuming we are using mainnet for those Smart Accounts }, [`${BtcAccountType.P2wpkh}`]: { methods: [...Object.values(BtcMethod)], - scopes: [BtcScopes.Mainnet], + scopes: [BtcScope.Mainnet], }, }; @@ -276,12 +283,12 @@ function setLastSelectedAsAny(account: InternalAccount): InternalAccount { } /** - * Builds a new instance of the ControllerMessenger class for the AccountsController. + * Builds a new instance of the Messenger class for the AccountsController. * - * @returns A new instance of the ControllerMessenger class for the AccountsController. + * @returns A new instance of the Messenger class for the AccountsController. */ function buildMessenger() { - return new ControllerMessenger< + return new Messenger< AccountsControllerActions | AllowedActions, AccountsControllerEvents | AllowedEvents >(); @@ -299,6 +306,10 @@ function buildAccountsControllerMessenger(messenger = buildMessenger()) { allowedEvents: [ 'SnapController:stateChange', 'KeyringController:stateChange', + 'SnapKeyring:accountAssetListUpdated', + 'SnapKeyring:accountBalancesUpdated', + 'SnapKeyring:accountTransactionsUpdated', + 'MultichainNetworkController:networkDidChange', ], allowedActions: [ 'KeyringController:getAccounts', @@ -321,16 +332,17 @@ function setupAccountsController({ messenger = buildMessenger(), }: { initialState?: Partial; - messenger?: ControllerMessenger< + messenger?: Messenger< AccountsControllerActions | AllowedActions, AccountsControllerEvents | AllowedEvents >; }): { accountsController: AccountsController; - messenger: ControllerMessenger< + messenger: Messenger< AccountsControllerActions | AllowedActions, AccountsControllerEvents | AllowedEvents >; + triggerMultichainNetworkChange: (id: NetworkClientId | CaipChainId) => void; } { const accountsControllerMessenger = buildAccountsControllerMessenger(messenger); @@ -339,10 +351,37 @@ function setupAccountsController({ messenger: accountsControllerMessenger, state: { ...defaultState, ...initialState }, }); - return { accountsController, messenger }; + + const triggerMultichainNetworkChange = (id: NetworkClientId | CaipChainId) => + messenger.publish('MultichainNetworkController:networkDidChange', id); + + return { accountsController, messenger, triggerMultichainNetworkChange }; } describe('AccountsController', () => { + const mockBtcAccount = createExpectedInternalAccount({ + id: 'mock-non-evm', + name: 'non-evm', + address: 'bc1qzqc2aqlw8nwa0a05ehjkk7dgt8308ac7kzw9a6', + keyringType: KeyringTypes.snap, + type: BtcAccountType.P2wpkh, + }); + + const mockOlderEvmAccount = createExpectedInternalAccount({ + id: 'mock-id-1', + name: 'mock account 1', + address: 'mock-address-1', + keyringType: KeyringTypes.hd, + lastSelected: 11111, + }); + const mockNewerEvmAccount = createExpectedInternalAccount({ + id: 'mock-id-2', + name: 'mock account 2', + address: 'mock-address-2', + keyringType: KeyringTypes.hd, + lastSelected: 22222, + }); + describe('onSnapStateChange', () => { it('be used enable an account if the Snap is enabled and not blocked', async () => { const messenger = buildMessenger(); @@ -1393,6 +1432,172 @@ describe('AccountsController', () => { ); }); + describe('onSnapKeyringEvents', () => { + const setupTest = () => { + const account = createExpectedInternalAccount({ + id: 'mock-id', + name: 'Bitcoin Account', + address: 'tb1q4q7h8wuplrpmkxqvv6rrrq7qyhhjsj5uqcsxqu', + keyringType: KeyringTypes.snap, + snapId: 'mock-snap', + type: BtcAccountType.P2wpkh, + }); + + const messenger = buildMessenger(); + const { accountsController } = setupAccountsController({ + initialState: { + internalAccounts: { + accounts: { + [account.id]: account, + }, + selectedAccount: account.id, + }, + }, + messenger, + }); + + return { messenger, account, accountsController }; + }; + + it('re-publishes keyring events: SnapKeyring:accountBalancesUpdated', () => { + const { account, messenger } = setupTest(); + + const payload: AccountBalancesUpdatedEventPayload = { + balances: { + [account.id]: { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + amount: '0.1', + unit: 'BTC', + }, + }, + }, + }; + + const mockRePublishedCallback = jest.fn(); + messenger.subscribe( + 'AccountsController:accountBalancesUpdated', + mockRePublishedCallback, + ); + messenger.publish('SnapKeyring:accountBalancesUpdated', payload); + expect(mockRePublishedCallback).toHaveBeenCalledWith(payload); + }); + + it('re-publishes keyring events: SnapKeyring:accountAssetListUpdated', () => { + const { account, messenger } = setupTest(); + + const payload: AccountAssetListUpdatedEventPayload = { + assets: { + [account.id]: { + added: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], + removed: ['bip122:000000000933ea01ad0ee984209779ba/slip44:0'], + }, + }, + }; + + const mockRePublishedCallback = jest.fn(); + messenger.subscribe( + 'AccountsController:accountAssetListUpdated', + mockRePublishedCallback, + ); + messenger.publish('SnapKeyring:accountAssetListUpdated', payload); + expect(mockRePublishedCallback).toHaveBeenCalledWith(payload); + }); + + it('re-publishes keyring events: SnapKeyring:accountTransactionsUpdated', () => { + const { account, messenger } = setupTest(); + + const payload: AccountTransactionsUpdatedEventPayload = { + transactions: { + [account.id]: [ + { + id: 'f5d8ee39a430901c91a5917b9f2dc19d6d1a0e9cea205b009ca73dd04470b9a6', + timestamp: null, + chain: 'bip122:000000000019d6689c085ae165831e93', + status: 'submitted', + type: 'receive', + account: account.id, + from: [], + to: [], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + unit: 'BTC', + amount: '0.0001', + }, + }, + ], + events: [], + }, + ], + }, + }; + + const mockRePublishedCallback = jest.fn(); + messenger.subscribe( + 'AccountsController:accountTransactionsUpdated', + mockRePublishedCallback, + ); + messenger.publish('SnapKeyring:accountTransactionsUpdated', payload); + expect(mockRePublishedCallback).toHaveBeenCalledWith(payload); + }); + }); + + describe('handle MultichainNetworkController:networkDidChange event', () => { + it('should update selected account to non-EVM account when switching to non-EVM network', () => { + const messenger = buildMessenger(); + const { accountsController, triggerMultichainNetworkChange } = + setupAccountsController({ + initialState: { + internalAccounts: { + accounts: { + [mockOlderEvmAccount.id]: mockOlderEvmAccount, + [mockNewerEvmAccount.id]: mockNewerEvmAccount, + [mockBtcAccount.id]: mockBtcAccount, + }, + selectedAccount: mockNewerEvmAccount.id, + }, + }, + messenger, + }); + + // Triggered from network switch to Bitcoin mainnet + triggerMultichainNetworkChange(BtcScope.Mainnet); + + // BTC account is now selected + expect(accountsController.state.internalAccounts.selectedAccount).toBe( + mockBtcAccount.id, + ); + }); + + it('should update selected account to EVM account when switching to EVM network', () => { + const messenger = buildMessenger(); + const { accountsController, triggerMultichainNetworkChange } = + setupAccountsController({ + initialState: { + internalAccounts: { + accounts: { + [mockOlderEvmAccount.id]: mockOlderEvmAccount, + [mockBtcAccount.id]: mockBtcAccount, + }, + selectedAccount: mockBtcAccount.id, + }, + }, + messenger, + }); + + // Triggered from network switch to Bitcoin mainnet + triggerMultichainNetworkChange(InfuraNetworkType.mainnet); + + // ETH mainnet account is now selected + expect(accountsController.state.internalAccounts.selectedAccount).toBe( + mockOlderEvmAccount.id, + ); + }); + }); + describe('updateAccounts', () => { const mockAddress1 = '0x123'; const mockAddress2 = '0x456'; @@ -1754,6 +1959,7 @@ describe('AccountsController', () => { KeyringTypes.simple, KeyringTypes.hd, KeyringTypes.trezor, + KeyringTypes.oneKey, KeyringTypes.ledger, KeyringTypes.lattice, KeyringTypes.qr, @@ -2023,29 +2229,6 @@ describe('AccountsController', () => { }); describe('getSelectedAccount', () => { - const mockNonEvmAccount = createExpectedInternalAccount({ - id: 'mock-non-evm', - name: 'non-evm', - address: 'bc1qzqc2aqlw8nwa0a05ehjkk7dgt8308ac7kzw9a6', - keyringType: KeyringTypes.snap, - type: BtcAccountType.P2wpkh, - }); - - const mockOlderEvmAccount = createExpectedInternalAccount({ - id: 'mock-id-1', - name: 'mock account 1', - address: 'mock-address-1', - keyringType: KeyringTypes.hd, - lastSelected: 11111, - }); - const mockNewerEvmAccount = createExpectedInternalAccount({ - id: 'mock-id-2', - name: 'mock account 2', - address: 'mock-address-2', - keyringType: KeyringTypes.hd, - lastSelected: 22222, - }); - it.each([ { lastSelectedAccount: mockNewerEvmAccount, @@ -2056,7 +2239,7 @@ describe('AccountsController', () => { expected: mockOlderEvmAccount, }, { - lastSelectedAccount: mockNonEvmAccount, + lastSelectedAccount: mockBtcAccount, expected: mockNewerEvmAccount, }, ])( @@ -2068,7 +2251,7 @@ describe('AccountsController', () => { accounts: { [mockOlderEvmAccount.id]: mockOlderEvmAccount, [mockNewerEvmAccount.id]: mockNewerEvmAccount, - [mockNonEvmAccount.id]: mockNonEvmAccount, + [mockBtcAccount.id]: mockBtcAccount, }, selectedAccount: lastSelectedAccount.id, }, @@ -2084,9 +2267,9 @@ describe('AccountsController', () => { initialState: { internalAccounts: { accounts: { - [mockNonEvmAccount.id]: mockNonEvmAccount, + [mockBtcAccount.id]: mockBtcAccount, }, - selectedAccount: mockNonEvmAccount.id, + selectedAccount: mockBtcAccount.id, }, }, }); @@ -2113,29 +2296,6 @@ describe('AccountsController', () => { }); describe('getSelectedMultichainAccount', () => { - const mockNonEvmAccount = createExpectedInternalAccount({ - id: 'mock-non-evm', - name: 'non-evm', - address: 'bc1qzqc2aqlw8nwa0a05ehjkk7dgt8308ac7kzw9a6', - keyringType: KeyringTypes.snap, - type: BtcAccountType.P2wpkh, - }); - - const mockOlderEvmAccount = createExpectedInternalAccount({ - id: 'mock-id-1', - name: 'mock account 1', - address: 'mock-address-1', - keyringType: KeyringTypes.hd, - lastSelected: 11111, - }); - const mockNewerEvmAccount = createExpectedInternalAccount({ - id: 'mock-id-2', - name: 'mock account 2', - address: 'mock-address-2', - keyringType: KeyringTypes.hd, - lastSelected: 22222, - }); - it.each([ { chainId: undefined, @@ -2144,18 +2304,18 @@ describe('AccountsController', () => { }, { chainId: undefined, - selectedAccount: mockNonEvmAccount, - expected: mockNonEvmAccount, + selectedAccount: mockBtcAccount, + expected: mockBtcAccount, }, { chainId: 'eip155:1', - selectedAccount: mockNonEvmAccount, + selectedAccount: mockBtcAccount, expected: mockNewerEvmAccount, }, { chainId: 'bip122:000000000019d6689c085ae165831e93', - selectedAccount: mockNonEvmAccount, - expected: mockNonEvmAccount, + selectedAccount: mockBtcAccount, + expected: mockBtcAccount, }, ])( "chainId $chainId with selectedAccount '$selectedAccount.id' should return $expected.id", @@ -2166,7 +2326,7 @@ describe('AccountsController', () => { accounts: { [mockOlderEvmAccount.id]: mockOlderEvmAccount, [mockNewerEvmAccount.id]: mockNewerEvmAccount, - [mockNonEvmAccount.id]: mockNonEvmAccount, + [mockBtcAccount.id]: mockBtcAccount, }, selectedAccount: selectedAccount.id, }, @@ -2191,9 +2351,9 @@ describe('AccountsController', () => { accounts: { [mockOlderEvmAccount.id]: mockOlderEvmAccount, [mockNewerEvmAccount.id]: mockNewerEvmAccount, - [mockNonEvmAccount.id]: mockNonEvmAccount, + [mockBtcAccount.id]: mockBtcAccount, }, - selectedAccount: mockNonEvmAccount.id, + selectedAccount: mockBtcAccount.id, }, }, }); diff --git a/packages/accounts-controller/src/AccountsController.ts b/packages/accounts-controller/src/AccountsController.ts index f2feed0e074..84ea1a113ad 100644 --- a/packages/accounts-controller/src/AccountsController.ts +++ b/packages/accounts-controller/src/AccountsController.ts @@ -1,40 +1,47 @@ -import type { - ControllerGetStateAction, - ControllerStateChangeEvent, - RestrictedControllerMessenger, +import { + type ControllerGetStateAction, + type ControllerStateChangeEvent, + type ExtractEventPayload, + type RestrictedMessenger, + BaseController, } from '@metamask/base-controller'; -import { BaseController } from '@metamask/base-controller'; -import { SnapKeyring } from '@metamask/eth-snap-keyring'; +import { + type SnapKeyringAccountAssetListUpdatedEvent, + type SnapKeyringAccountBalancesUpdatedEvent, + type SnapKeyringAccountTransactionsUpdatedEvent, + SnapKeyring, +} from '@metamask/eth-snap-keyring'; import { EthAccountType, EthMethod, - EthScopes, + EthScope, isEvmAccountType, } from '@metamask/keyring-api'; -import { KeyringTypes } from '@metamask/keyring-controller'; -import type { - KeyringControllerState, - KeyringControllerGetKeyringForAccountAction, - KeyringControllerGetKeyringsByTypeAction, - KeyringControllerGetAccountsAction, - KeyringControllerStateChangeEvent, +import { + type KeyringControllerState, + type KeyringControllerGetKeyringForAccountAction, + type KeyringControllerGetKeyringsByTypeAction, + type KeyringControllerGetAccountsAction, + type KeyringControllerStateChangeEvent, + KeyringTypes, } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { NetworkClientId } from '@metamask/network-controller'; import type { SnapControllerState, SnapStateChange, } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; import type { Snap } from '@metamask/snaps-utils'; -import type { CaipChainId } from '@metamask/utils'; import { type Keyring, type Json, + type CaipChainId, isCaipChainId, parseCaipChainId, } from '@metamask/utils'; -import type { Draft } from 'immer'; +import type { MultichainNetworkControllerNetworkDidChangeEvent } from './types'; import { getUUIDFromAddressOfNormalAccount, isNormalKeyringType, @@ -161,7 +168,28 @@ export type AccountsControllerAccountRenamedEvent = { payload: [InternalAccount]; }; -export type AllowedEvents = SnapStateChange | KeyringControllerStateChangeEvent; +export type AccountsControllerAccountBalancesUpdatesEvent = { + type: `${typeof controllerName}:accountBalancesUpdated`; + payload: SnapKeyringAccountBalancesUpdatedEvent['payload']; +}; + +export type AccountsControllerAccountTransactionsUpdatedEvent = { + type: `${typeof controllerName}:accountTransactionsUpdated`; + payload: SnapKeyringAccountTransactionsUpdatedEvent['payload']; +}; + +export type AccountsControllerAccountAssetListUpdatedEvent = { + type: `${typeof controllerName}:accountAssetListUpdated`; + payload: SnapKeyringAccountAssetListUpdatedEvent['payload']; +}; + +export type AllowedEvents = + | SnapStateChange + | KeyringControllerStateChangeEvent + | SnapKeyringAccountAssetListUpdatedEvent + | SnapKeyringAccountBalancesUpdatedEvent + | SnapKeyringAccountTransactionsUpdatedEvent + | MultichainNetworkControllerNetworkDidChangeEvent; export type AccountsControllerEvents = | AccountsControllerChangeEvent @@ -169,9 +197,12 @@ export type AccountsControllerEvents = | AccountsControllerSelectedEvmAccountChangeEvent | AccountsControllerAccountAddedEvent | AccountsControllerAccountRemovedEvent - | AccountsControllerAccountRenamedEvent; + | AccountsControllerAccountRenamedEvent + | AccountsControllerAccountBalancesUpdatesEvent + | AccountsControllerAccountTransactionsUpdatedEvent + | AccountsControllerAccountAssetListUpdatedEvent; -export type AccountsControllerMessenger = RestrictedControllerMessenger< +export type AccountsControllerMessenger = RestrictedMessenger< typeof controllerName, AccountsControllerActions | AllowedActions, AccountsControllerEvents | AllowedEvents, @@ -204,7 +235,7 @@ export const EMPTY_ACCOUNT = { options: {}, methods: [], type: EthAccountType.Eoa, - scopes: [EthScopes.Namespace], + scopes: [EthScope.Eoa], metadata: { name: '', keyring: { @@ -251,16 +282,7 @@ export class AccountsController extends BaseController< }, }); - this.messagingSystem.subscribe( - 'SnapController:stateChange', - (snapStateState) => this.#handleOnSnapStateChange(snapStateState), - ); - - this.messagingSystem.subscribe( - 'KeyringController:stateChange', - (keyringState) => this.#handleOnKeyringStateChange(keyringState), - ); - + this.#subscribeToMessageEvents(); this.#registerMessageHandlers(); } @@ -386,6 +408,7 @@ export class AccountsController extends BaseController< /** * Returns the account with the specified address. * ! This method will only return the first account that matches the address + * * @param address - The address of the account to retrieve. * @returns The account with the specified address, or undefined if not found. */ @@ -403,7 +426,7 @@ export class AccountsController extends BaseController< setSelectedAccount(accountId: string): void { const account = this.getAccountExpect(accountId); - this.update((currentState: Draft) => { + this.update((currentState) => { currentState.internalAccounts.accounts[account.id].metadata.lastSelected = Date.now(); currentState.internalAccounts.selectedAccount = account.id; @@ -451,7 +474,7 @@ export class AccountsController extends BaseController< throw new Error('Account name already exists'); } - this.update((currentState: Draft) => { + this.update((currentState) => { const internalAccount = { ...account, metadata: { ...account.metadata, ...metadata }, @@ -487,42 +510,47 @@ export class AccountsController extends BaseController< const accounts: Record = [ ...normalAccounts, ...snapAccounts, - ].reduce((internalAccountMap, internalAccount) => { - const keyringTypeName = keyringTypeToName( - internalAccount.metadata.keyring.type, - ); - const keyringAccountIndex = keyringTypes.get(keyringTypeName) ?? 0; - if (keyringAccountIndex) { - keyringTypes.set(keyringTypeName, keyringAccountIndex + 1); - } else { - keyringTypes.set(keyringTypeName, 1); - } - - const existingAccount = previousAccounts[internalAccount.id]; - - internalAccountMap[internalAccount.id] = { - ...internalAccount, + ].reduce( + (internalAccountMap, internalAccount) => { + const keyringTypeName = keyringTypeToName( + internalAccount.metadata.keyring.type, + ); + const keyringAccountIndex = keyringTypes.get(keyringTypeName) ?? 0; + if (keyringAccountIndex) { + keyringTypes.set(keyringTypeName, keyringAccountIndex + 1); + } else { + keyringTypes.set(keyringTypeName, 1); + } - metadata: { - ...internalAccount.metadata, - name: - this.#populateExistingMetadata(existingAccount?.id, 'name') ?? - `${keyringTypeName} ${keyringAccountIndex + 1}`, - importTime: - this.#populateExistingMetadata(existingAccount?.id, 'importTime') ?? - Date.now(), - lastSelected: - this.#populateExistingMetadata( - existingAccount?.id, - 'lastSelected', - ) ?? 0, - }, - }; + const existingAccount = previousAccounts[internalAccount.id]; + + internalAccountMap[internalAccount.id] = { + ...internalAccount, + + metadata: { + ...internalAccount.metadata, + name: + this.#populateExistingMetadata(existingAccount?.id, 'name') ?? + `${keyringTypeName} ${keyringAccountIndex + 1}`, + importTime: + this.#populateExistingMetadata( + existingAccount?.id, + 'importTime', + ) ?? Date.now(), + lastSelected: + this.#populateExistingMetadata( + existingAccount?.id, + 'lastSelected', + ) ?? 0, + }, + }; - return internalAccountMap; - }, {} as Record); + return internalAccountMap; + }, + {} as Record, + ); - this.update((currentState: Draft) => { + this.update((currentState) => { currentState.internalAccounts.accounts = accounts; if ( @@ -556,7 +584,7 @@ export class AccountsController extends BaseController< */ loadBackup(backup: AccountsControllerState): void { if (backup.internalAccounts) { - this.update((currentState: Draft) => { + this.update((currentState) => { currentState.internalAccounts = backup.internalAccounts; }); } @@ -564,6 +592,7 @@ export class AccountsController extends BaseController< /** * Generates an internal account for a non-Snap account. + * * @param address - The address of the account. * @param type - The type of the account. * @returns The generated internal account. @@ -584,7 +613,7 @@ export class AccountsController extends BaseController< EthMethod.SignTypedDataV3, EthMethod.SignTypedDataV4, ], - scopes: [EthScopes.Namespace], + scopes: [EthScope.Eoa], type: EthAccountType.Eoa, metadata: { name: '', @@ -659,7 +688,7 @@ export class AccountsController extends BaseController< EthMethod.SignTypedDataV3, EthMethod.SignTypedDataV4, ], - scopes: [EthScopes.Namespace], + scopes: [EthScope.Eoa], type: EthAccountType.Eoa, metadata: { name: this.#populateExistingMetadata(id, 'name') ?? '', @@ -677,6 +706,23 @@ export class AccountsController extends BaseController< return internalAccounts; } + /** + * Re-publish an account event. + * + * @param event - The event type. This is a unique identifier for this event. + * @param payload - The event payload. The type of the parameters for each event handler must + * match the type of this payload. + * @template EventType - A Snap keyring event type. + */ + #handleOnSnapKeyringAccountEvent< + EventType extends AccountsControllerEvents['type'], + >( + event: EventType, + ...payload: ExtractEventPayload + ): void { + this.messagingSystem.publish(event, ...payload); + } + /** * Handles changes in the keyring state, specifically when new accounts are added or removed. * @@ -786,7 +832,7 @@ export class AccountsController extends BaseController< } } - this.update((currentState: Draft) => { + this.update((currentState) => { if (deletedAccounts.length > 0) { for (const account of deletedAccounts) { currentState.internalAccounts.accounts = this.#handleAccountRemoved( @@ -848,7 +894,7 @@ export class AccountsController extends BaseController< (account) => account.metadata.snap, ); - this.update((currentState: Draft) => { + this.update((currentState) => { accounts.forEach((account) => { const currentAccount = currentState.internalAccounts.accounts[account.id]; @@ -866,6 +912,7 @@ export class AccountsController extends BaseController< /** * Returns the list of accounts for a given keyring type. + * * @param keyringType - The type of keyring. * @param accounts - Accounts to filter by keyring type. * @returns The list of accounts associcated with this keyring type. @@ -912,6 +959,7 @@ export class AccountsController extends BaseController< /** * Returns the next account number for a given keyring type. + * * @param keyringType - The type of keyring. * @param accounts - Existing accounts to check for the next available account number. * @returns An object containing the account prefix and index to use. @@ -949,8 +997,6 @@ export class AccountsController extends BaseController< const index = Math.max( keyringAccounts.length + 1, - // ESLint is confused; this is a number. - // eslint-disable-next-line @typescript-eslint/restrict-plus-operands lastDefaultIndexUsedForKeyringType + 1, ); @@ -959,7 +1005,7 @@ export class AccountsController extends BaseController< /** * Checks if an account is compatible with a given chain namespace. - * @private + * * @param account - The account to check compatibility for. * @param chainId - The CAIP2 to check compatibility with. * @returns Returns true if the account is compatible with the chain namespace, otherwise false. @@ -988,6 +1034,7 @@ export class AccountsController extends BaseController< * Handles the addition of a new account to the controller. * If the account is not a Snap Keyring account, generates an internal account for it and adds it to the controller. * If the account is a Snap Keyring account, retrieves the account from the keyring and adds it to the controller. + * * @param accountsState - AccountsController accounts state that is to be mutated. * @param account - The address and keyring type object of the new account. * @returns The updated AccountsController accounts state. @@ -1060,6 +1107,7 @@ export class AccountsController extends BaseController< /** * Handles the removal of an account from the internal accounts list. + * * @param accountsState - AccountsController accounts state that is to be mutated. * @param accountId - The ID of the account to be removed. * @returns The updated AccountsController state. @@ -1078,15 +1126,45 @@ export class AccountsController extends BaseController< return accountsState; } + /** + * Handles the change in multichain network by updating the selected account. + * + * @param id - The EVM client ID or non-EVM chain ID that changed. + */ + #handleOnMultichainNetworkDidChange(id: NetworkClientId | CaipChainId) { + let accountId: string; + + // We only support non-EVM Caip chain IDs at the moment. Ex Solana and Bitcoin + // MultichainNetworkController will handle throwing an error if the Caip chain ID is not supported + if (isCaipChainId(id)) { + // Update selected account to non evm account + const lastSelectedNonEvmAccount = this.getSelectedMultichainAccount(id); + // @ts-expect-error - This should never be undefined, otherwise it's a bug that should be handled + accountId = lastSelectedNonEvmAccount.id; + } else { + // Update selected account to evm account + const lastSelectedEvmAccount = this.getSelectedAccount(); + accountId = lastSelectedEvmAccount.id; + } + + this.update((currentState) => { + currentState.internalAccounts.accounts[accountId].metadata.lastSelected = + Date.now(); + currentState.internalAccounts.selectedAccount = accountId; + }); + + // DO NOT publish AccountsController:setSelectedAccount to prevent circular listener loops + } + /** * Retrieves the value of a specific metadata key for an existing account. + * * @param accountId - The ID of the account. * @param metadataKey - The key of the metadata to retrieve. * @param account - The account object to retrieve the metadata key from. * @returns The value of the specified metadata key, or undefined if the account or metadata key does not exist. */ // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention #populateExistingMetadata( accountId: string, metadataKey: T, @@ -1096,9 +1174,56 @@ export class AccountsController extends BaseController< return internalAccount ? internalAccount.metadata[metadataKey] : undefined; } + /** + * Subscribes to message events. + */ + #subscribeToMessageEvents() { + this.messagingSystem.subscribe( + 'SnapController:stateChange', + (snapStateState) => this.#handleOnSnapStateChange(snapStateState), + ); + + this.messagingSystem.subscribe( + 'KeyringController:stateChange', + (keyringState) => this.#handleOnKeyringStateChange(keyringState), + ); + + this.messagingSystem.subscribe( + 'SnapKeyring:accountAssetListUpdated', + (snapAccountEvent) => + this.#handleOnSnapKeyringAccountEvent( + 'AccountsController:accountAssetListUpdated', + snapAccountEvent, + ), + ); + + this.messagingSystem.subscribe( + 'SnapKeyring:accountBalancesUpdated', + (snapAccountEvent) => + this.#handleOnSnapKeyringAccountEvent( + 'AccountsController:accountBalancesUpdated', + snapAccountEvent, + ), + ); + + this.messagingSystem.subscribe( + 'SnapKeyring:accountTransactionsUpdated', + (snapAccountEvent) => + this.#handleOnSnapKeyringAccountEvent( + 'AccountsController:accountTransactionsUpdated', + snapAccountEvent, + ), + ); + + // Handle account change when multichain network is changed + this.messagingSystem.subscribe( + 'MultichainNetworkController:networkDidChange', + (id) => this.#handleOnMultichainNetworkDidChange(id), + ); + } + /** * Registers message handlers for the AccountsController. - * @private */ #registerMessageHandlers() { this.messagingSystem.registerActionHandler( diff --git a/packages/accounts-controller/src/index.ts b/packages/accounts-controller/src/index.ts index 2ac23b5a258..2c9d9fa71c9 100644 --- a/packages/accounts-controller/src/index.ts +++ b/packages/accounts-controller/src/index.ts @@ -1,4 +1,5 @@ export type { + AccountId, AccountsControllerState, AccountsControllerGetStateAction, AccountsControllerSetSelectedAccountAction, @@ -9,9 +10,10 @@ export type { AccountsControllerGetSelectedAccountAction, AccountsControllerGetSelectedMultichainAccountAction, AccountsControllerGetAccountByAddressAction, - AccountsControllerGetAccountAction, AccountsControllerGetNextAvailableAccountNameAction, + AccountsControllerGetAccountAction, AccountsControllerUpdateAccountMetadataAction, + AllowedActions, AccountsControllerActions, AccountsControllerChangeEvent, AccountsControllerSelectedAccountChangeEvent, @@ -19,8 +21,17 @@ export type { AccountsControllerAccountAddedEvent, AccountsControllerAccountRemovedEvent, AccountsControllerAccountRenamedEvent, + AccountsControllerAccountBalancesUpdatesEvent, + AccountsControllerAccountTransactionsUpdatedEvent, + AccountsControllerAccountAssetListUpdatedEvent, + AllowedEvents, AccountsControllerEvents, AccountsControllerMessenger, } from './AccountsController'; -export { AccountsController } from './AccountsController'; -export { keyringTypeToName, getUUIDFromAddressOfNormalAccount } from './utils'; +export { EMPTY_ACCOUNT, AccountsController } from './AccountsController'; +export { + keyringTypeToName, + getUUIDOptionsFromAddressOfNormalAccount, + getUUIDFromAddressOfNormalAccount, + isNormalKeyringType, +} from './utils'; diff --git a/packages/accounts-controller/src/tests/mocks.ts b/packages/accounts-controller/src/tests/mocks.ts index c5224ab0be4..ab7e55eca81 100644 --- a/packages/accounts-controller/src/tests/mocks.ts +++ b/packages/accounts-controller/src/tests/mocks.ts @@ -4,11 +4,9 @@ import { BtcMethod, EthMethod, } from '@metamask/keyring-api'; +import type { KeyringAccountType } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; -import type { - InternalAccount, - InternalAccountType, -} from '@metamask/keyring-internal-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; import { v4 } from 'uuid'; export const createMockInternalAccount = ({ @@ -23,7 +21,7 @@ export const createMockInternalAccount = ({ }: { id?: string; address?: string; - type?: InternalAccountType; + type?: KeyringAccountType; name?: string; keyringType?: KeyringTypes; snap?: { diff --git a/packages/accounts-controller/src/types.ts b/packages/accounts-controller/src/types.ts new file mode 100644 index 00000000000..1ee9421ec42 --- /dev/null +++ b/packages/accounts-controller/src/types.ts @@ -0,0 +1,10 @@ +// This file contains duplicate code from MultichainNetworkController.ts to avoid circular dependencies +// It should be refactored to avoid duplication + +import type { CaipChainId } from '@metamask/keyring-api'; +import type { NetworkClientId } from '@metamask/network-controller'; + +export type MultichainNetworkControllerNetworkDidChangeEvent = { + type: `MultichainNetworkController:networkDidChange`; + payload: [NetworkClientId | CaipChainId]; +}; diff --git a/packages/accounts-controller/src/utils.ts b/packages/accounts-controller/src/utils.ts index d3cb5aede23..3562df9b566 100644 --- a/packages/accounts-controller/src/utils.ts +++ b/packages/accounts-controller/src/utils.ts @@ -27,6 +27,9 @@ export function keyringTypeToName(keyringType: string): string { case KeyringTypes.trezor: { return 'Trezor'; } + case KeyringTypes.oneKey: { + return 'OneKey'; + } case KeyringTypes.ledger: { return 'Ledger'; } @@ -47,6 +50,7 @@ export function keyringTypeToName(keyringType: string): string { /** * Generates a UUID v4 options from a given Ethereum address. + * * @param address - The Ethereum address to generate the UUID from. * @returns The UUID v4 options. */ @@ -62,6 +66,7 @@ export function getUUIDOptionsFromAddressOfNormalAccount( /** * Generates a UUID from a given Ethereum address. + * * @param address - The Ethereum address to generate the UUID from. * @returns The generated UUID. */ @@ -71,6 +76,7 @@ export function getUUIDFromAddressOfNormalAccount(address: string): string { /** * Check if a keyring type is considered a "normal" keyring. + * * @param keyringType - The account's keyring type. * @returns True if the keyring type is considered a "normal" keyring, false otherwise. */ diff --git a/packages/accounts-controller/tsconfig.build.json b/packages/accounts-controller/tsconfig.build.json index b4fbdd4821c..2ccd968d36d 100644 --- a/packages/accounts-controller/tsconfig.build.json +++ b/packages/accounts-controller/tsconfig.build.json @@ -10,7 +10,8 @@ { "path": "../base-controller/tsconfig.build.json" }, - { "path": "../keyring-controller/tsconfig.build.json" } + { "path": "../keyring-controller/tsconfig.build.json" }, + { "path": "../network-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/accounts-controller/tsconfig.json b/packages/accounts-controller/tsconfig.json index 7263c934b6b..12cd20ecb5c 100644 --- a/packages/accounts-controller/tsconfig.json +++ b/packages/accounts-controller/tsconfig.json @@ -9,7 +9,8 @@ }, { "path": "../keyring-controller" - } + }, + { "path": "../network-controller" } ], - "include": ["../../types", "./src"] + "include": ["../../types", "./src", "src/tests"] } diff --git a/packages/address-book-controller/CHANGELOG.md b/packages/address-book-controller/CHANGELOG.md index 5c12bdcc58a..ca22bbb1e55 100644 --- a/packages/address-book-controller/CHANGELOG.md +++ b/packages/address-book-controller/CHANGELOG.md @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [6.0.3] + ### Changed -- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) +- Bump `@metamask/base-controller` from `^7.0.2` to `^8.0.0` ([#5079](https://github.com/MetaMask/core/pull/5079)), ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/rpc-errors` from `^7.0.1` to `^7.0.2` ([#5080](https://github.com/MetaMask/core/pull/5080)) +- Bump `@metamask/utils` from `^10.0.0` to `^11.1.0` ([#5080](https://github.com/MetaMask/core/pull/5080)), ([#5223](https://github.com/MetaMask/core/pull/5223)) ## [6.0.2] @@ -198,7 +203,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/address-book-controller@6.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/address-book-controller@6.0.3...HEAD +[6.0.3]: https://github.com/MetaMask/core/compare/@metamask/address-book-controller@6.0.2...@metamask/address-book-controller@6.0.3 [6.0.2]: https://github.com/MetaMask/core/compare/@metamask/address-book-controller@6.0.1...@metamask/address-book-controller@6.0.2 [6.0.1]: https://github.com/MetaMask/core/compare/@metamask/address-book-controller@6.0.0...@metamask/address-book-controller@6.0.1 [6.0.0]: https://github.com/MetaMask/core/compare/@metamask/address-book-controller@5.0.0...@metamask/address-book-controller@6.0.0 diff --git a/packages/address-book-controller/package.json b/packages/address-book-controller/package.json index 748222c7563..d83cb418d39 100644 --- a/packages/address-book-controller/package.json +++ b/packages/address-book-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/address-book-controller", - "version": "6.0.2", + "version": "6.0.3", "description": "Manages a list of recipient addresses associated with nicknames", "keywords": [ "MetaMask", @@ -47,9 +47,9 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/controller-utils": "^11.4.5", - "@metamask/utils": "^11.0.1" + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", + "@metamask/utils": "^11.1.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", diff --git a/packages/address-book-controller/src/AddressBookController.test.ts b/packages/address-book-controller/src/AddressBookController.test.ts index 14e28d4e27d..f2fa616652a 100644 --- a/packages/address-book-controller/src/AddressBookController.test.ts +++ b/packages/address-book-controller/src/AddressBookController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { toHex } from '@metamask/controller-utils'; import type { @@ -17,11 +17,11 @@ import { * @returns A restricted controller messenger. */ function getRestrictedMessenger() { - const controllerMessenger = new ControllerMessenger< + const messenger = new Messenger< AddressBookControllerActions, AddressBookControllerEvents >(); - return controllerMessenger.getRestricted({ + return messenger.getRestricted({ name: controllerName, allowedActions: [], allowedEvents: [], diff --git a/packages/address-book-controller/src/AddressBookController.ts b/packages/address-book-controller/src/AddressBookController.ts index 6e4540638b6..cf1f1239d7b 100644 --- a/packages/address-book-controller/src/AddressBookController.ts +++ b/packages/address-book-controller/src/AddressBookController.ts @@ -1,7 +1,7 @@ import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import { @@ -122,7 +122,7 @@ export const getDefaultAddressBookControllerState = /** * The messenger of the {@link AddressBookController} for communication. */ -export type AddressBookControllerMessenger = RestrictedControllerMessenger< +export type AddressBookControllerMessenger = RestrictedMessenger< typeof controllerName, AddressBookControllerActions, AddressBookControllerEvents, diff --git a/packages/announcement-controller/CHANGELOG.md b/packages/announcement-controller/CHANGELOG.md index dfe0e30978f..3fedefeb8b6 100644 --- a/packages/announcement-controller/CHANGELOG.md +++ b/packages/announcement-controller/CHANGELOG.md @@ -7,9 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.0.3] + ### Changed -- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) +- Bump `@metamask/base-controller` from `^7.0.2` to `^8.0.0` ([#5079](https://github.com/MetaMask/core/pull/5079)), ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) ## [7.0.2] @@ -174,7 +176,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/announcement-controller@7.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/announcement-controller@7.0.3...HEAD +[7.0.3]: https://github.com/MetaMask/core/compare/@metamask/announcement-controller@7.0.2...@metamask/announcement-controller@7.0.3 [7.0.2]: https://github.com/MetaMask/core/compare/@metamask/announcement-controller@7.0.1...@metamask/announcement-controller@7.0.2 [7.0.1]: https://github.com/MetaMask/core/compare/@metamask/announcement-controller@7.0.0...@metamask/announcement-controller@7.0.1 [7.0.0]: https://github.com/MetaMask/core/compare/@metamask/announcement-controller@6.1.1...@metamask/announcement-controller@7.0.0 diff --git a/packages/announcement-controller/package.json b/packages/announcement-controller/package.json index 5a3feb68707..78d83b74cd5 100644 --- a/packages/announcement-controller/package.json +++ b/packages/announcement-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/announcement-controller", - "version": "7.0.2", + "version": "7.0.3", "description": "Manages in-app announcements", "keywords": [ "MetaMask", @@ -47,7 +47,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1" + "@metamask/base-controller": "^8.0.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", diff --git a/packages/announcement-controller/src/AnnouncementController.test.ts b/packages/announcement-controller/src/AnnouncementController.test.ts index a0834bd0449..56991f6ff53 100644 --- a/packages/announcement-controller/src/AnnouncementController.test.ts +++ b/packages/announcement-controller/src/AnnouncementController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import type { AnnouncementControllerState, @@ -17,11 +17,11 @@ const name = 'AnnouncementController'; * @returns A restricted controller messenger. */ function getRestrictedMessenger() { - const controllerMessenger = new ControllerMessenger< + const messenger = new Messenger< AnnouncementControllerActions, AnnouncementControllerEvents >(); - return controllerMessenger.getRestricted({ + return messenger.getRestricted({ name, allowedActions: [], allowedEvents: [], diff --git a/packages/announcement-controller/src/AnnouncementController.ts b/packages/announcement-controller/src/AnnouncementController.ts index 3e114ff3247..8bb1bb35c65 100644 --- a/packages/announcement-controller/src/AnnouncementController.ts +++ b/packages/announcement-controller/src/AnnouncementController.ts @@ -1,7 +1,7 @@ import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; @@ -66,7 +66,7 @@ const metadata = { }, }; -export type AnnouncementControllerMessenger = RestrictedControllerMessenger< +export type AnnouncementControllerMessenger = RestrictedMessenger< typeof controllerName, AnnouncementControllerActions, AnnouncementControllerEvents, diff --git a/packages/announcement-controller/src/index.ts b/packages/announcement-controller/src/index.ts index f3ce26e85e4..86cac4c8a2f 100644 --- a/packages/announcement-controller/src/index.ts +++ b/packages/announcement-controller/src/index.ts @@ -1 +1,11 @@ -export * from './AnnouncementController'; +export type { + AnnouncementMap, + StateAnnouncementMap, + AnnouncementControllerState, + AnnouncementControllerActions, + AnnouncementControllerEvents, + AnnouncementControllerGetStateAction, + AnnouncementControllerStateChangeEvent, + AnnouncementControllerMessenger, +} from './AnnouncementController'; +export { AnnouncementController } from './AnnouncementController'; diff --git a/packages/approval-controller/CHANGELOG.md b/packages/approval-controller/CHANGELOG.md index cd4fb7b7a20..ee1200988ee 100644 --- a/packages/approval-controller/CHANGELOG.md +++ b/packages/approval-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.1.3] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.0` to `^8.0.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + ## [7.1.2] ### Changed @@ -262,7 +269,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/approval-controller@7.1.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/approval-controller@7.1.3...HEAD +[7.1.3]: https://github.com/MetaMask/core/compare/@metamask/approval-controller@7.1.2...@metamask/approval-controller@7.1.3 [7.1.2]: https://github.com/MetaMask/core/compare/@metamask/approval-controller@7.1.1...@metamask/approval-controller@7.1.2 [7.1.1]: https://github.com/MetaMask/core/compare/@metamask/approval-controller@7.1.0...@metamask/approval-controller@7.1.1 [7.1.0]: https://github.com/MetaMask/core/compare/@metamask/approval-controller@7.0.4...@metamask/approval-controller@7.1.0 diff --git a/packages/approval-controller/package.json b/packages/approval-controller/package.json index 644d4c72b2f..d308df8ba67 100644 --- a/packages/approval-controller/package.json +++ b/packages/approval-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/approval-controller", - "version": "7.1.2", + "version": "7.1.3", "description": "Manages requests that require user approval", "keywords": [ "MetaMask", @@ -47,9 +47,9 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", + "@metamask/base-controller": "^8.0.0", "@metamask/rpc-errors": "^7.0.2", - "@metamask/utils": "^11.0.1", + "@metamask/utils": "^11.1.0", "nanoid": "^3.3.8" }, "devDependencies": { diff --git a/packages/approval-controller/src/ApprovalController.test.ts b/packages/approval-controller/src/ApprovalController.test.ts index 1664571a21b..18cc824b451 100644 --- a/packages/approval-controller/src/ApprovalController.test.ts +++ b/packages/approval-controller/src/ApprovalController.test.ts @@ -1,6 +1,6 @@ /* eslint-disable jest/expect-expect */ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { errorCodes, JsonRpcError } from '@metamask/rpc-errors'; import { nanoid } from 'nanoid'; @@ -223,21 +223,20 @@ function getError(message: string, code?: number) { } /** - * Constructs a restricted controller messenger. + * Constructs a restricted messenger. * - * @returns A restricted controller messenger. + * @returns A restricted messenger. */ function getRestrictedMessenger() { - const controllerMessenger = new ControllerMessenger< + const messenger = new Messenger< ApprovalControllerActions, ApprovalControllerEvents >(); - const messenger = controllerMessenger.getRestricted({ + return messenger.getRestricted({ name: 'ApprovalController', allowedActions: [], allowedEvents: [], }); - return messenger; } describe('approval controller', () => { @@ -1270,7 +1269,7 @@ describe('approval controller', () => { describe('actions', () => { it('addApprovalRequest: shouldShowRequest = true', async () => { - const messenger = new ControllerMessenger< + const messenger = new Messenger< ApprovalControllerActions, ApprovalControllerEvents >(); @@ -1296,7 +1295,7 @@ describe('approval controller', () => { }); it('addApprovalRequest: shouldShowRequest = false', async () => { - const messenger = new ControllerMessenger< + const messenger = new Messenger< ApprovalControllerActions, ApprovalControllerEvents >(); @@ -1322,7 +1321,7 @@ describe('approval controller', () => { }); it('updateRequestState', () => { - const messenger = new ControllerMessenger< + const messenger = new Messenger< ApprovalControllerActions, ApprovalControllerEvents >(); diff --git a/packages/approval-controller/src/ApprovalController.ts b/packages/approval-controller/src/ApprovalController.ts index 6ea4dd0b631..5b7398a83f8 100644 --- a/packages/approval-controller/src/ApprovalController.ts +++ b/packages/approval-controller/src/ApprovalController.ts @@ -2,7 +2,7 @@ import type { ControllerGetStateAction } from '@metamask/base-controller'; import { BaseController, type ControllerStateChangeEvent, - type RestrictedControllerMessenger, + type RestrictedMessenger, } from '@metamask/base-controller'; import type { JsonRpcError, DataWithOptionalCause } from '@metamask/rpc-errors'; import { rpcErrors } from '@metamask/rpc-errors'; @@ -119,7 +119,7 @@ export type ApprovalControllerState = { approvalFlows: ApprovalFlowState[]; }; -export type ApprovalControllerMessenger = RestrictedControllerMessenger< +export type ApprovalControllerMessenger = RestrictedMessenger< typeof controllerName, ApprovalControllerActions, ApprovalControllerEvents, @@ -367,7 +367,7 @@ export class ApprovalController extends BaseController< * @param options - The controller options. * @param options.showApprovalRequest - Function for opening the UI such that * the request can be displayed to the user. - * @param options.messenger - The restricted controller messenger for the Approval controller. + * @param options.messenger - The restricted messenger for the Approval controller. * @param options.state - The initial controller state. * @param options.typesExcludedFromRateLimiting - Array of approval types which allow multiple pending approval requests from the same origin. */ diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 4a62573da5b..a9dd141f502 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [50.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^23.0.1` to `^24.0.0` ([#5318](https://github.com/MetaMask/core/pull/5318)) +- Removed legacy poll function to prevent redundant polling ([#5321](https://github.com/MetaMask/core/pull/5321)) + +### Fixed + +- Ensure that the polling is not triggered on the constructor with the initialisation of the controller ([#5321](https://github.com/MetaMask/core/pull/5321)) + +## [49.0.0] + +### Added + +- Add new `MultiChainTokensRatesController` ([#5175](https://github.com/MetaMask/core/pull/5175)) + - A controller that manages multi‑chain token conversion rates within MetaMask. Its primary goal is to periodically poll for updated conversion rates of tokens associated with non‑EVM accounts (those using Snap metadata), ensuring that the conversion data remains up‑to‑date across supported chains. +- Add `updateBalance` to MultichainBalancesController ([#5295](https://github.com/MetaMask/core/pull/5295)) + +### Changed + +- **BREAKING:** MultichainBalancesController messenger must now allow `MultichainAssetsController:getState` action and `MultichainAssetsController:stateChange` event ([#5295](https://github.com/MetaMask/core/pull/5295)) +- Update `MultichainBalancesController` to get the full list of assets from `MultichainAssetsController` state instead of only requesting the native token ([#5295](https://github.com/MetaMask/core/pull/5295)) +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/polling-controller` from `^12.0.2` to `^12.0.3` ([#5305](https://github.com/MetaMask/core/pull/5305)) + +### Removed + +- **BREAKING:** `NETWORK_ASSETS_MAP`, `MultichainNetworks`, and `MultichainNativeAssets` are no longer exported ([#5295](https://github.com/MetaMask/core/pull/5295)) + +## [48.0.0] + +### Added + +- Add `MultichainAssetsController` for non-EVM assets ([#5138](https://github.com/MetaMask/core/pull/5138)) + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^22.0.0` to `^23.0.0` ([#5292](https://github.com/MetaMask/core/pull/5292)) +- Bump `@metamask/keyring-api"` from `^16.1.0` to `^17.0.0` ([#5280](https://github.com/MetaMask/core/pull/5280)) +- Bump `@metamask/snaps-utils` from `^8.9.0` to `^8.10.0` ([#5265](https://github.com/MetaMask/core/pull/5265)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) +- Removed polling mechanism in the `MultichainBalancesController` and now relies on the new `AccountsController:accountBalancesUpdated` event ([#5221](https://github.com/MetaMask/core/pull/5221)) + +### Fixed + +- The tokens state is now updated only when the `tokenChainId` matches the currently selected chain ID. ([#5257](https://github.com/MetaMask/core/pull/5257)) + +## [47.0.0] + +### Added + +- Add `onBreak` and `onDegraded` methods to `CodefiTokenPricesServiceV2` ([#5109](https://github.com/MetaMask/core/pull/5109)) + - These serve the same purpose as the `onBreak` and `onDegraded` constructor options, but align more closely with the Cockatiel policy API. + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^21.0.0` to `^22.0.0` ([#5218](https://github.com/MetaMask/core/pull/5218)) +- Deprecate `ClientConfigApiService` constructor options `onBreak` and `onDegraded` in favor of methods ([#5109](https://github.com/MetaMask/core/pull/5109)) +- Add `@metamask/controller-utils@^11.4.5` as a dependency ([#5109](https://github.com/MetaMask/core/pull/5109)) + - `cockatiel` should still be in the dependency tree because it's now a dependency of `@metamask/controller-utils` +- Re-introduce `@metamask/keyring-api` as a runtime dependency ([#5206](https://github.com/MetaMask/core/pull/5206)) + - This was required since the introduction of the `MultichainBalancesController`. +- Bump `@metamask/keyring-api` from `^14.0.0` to `^16.1.0` ([#5190](https://github.com/MetaMask/core/pull/5190)), ([#5208](https://github.com/MetaMask/core/pull/5208)) +- Bump `@metamask/keyring-internal-api` from `^2.0.1` to `^4.0.1` ([#5190](https://github.com/MetaMask/core/pull/5190)), ([#5208](https://github.com/MetaMask/core/pull/5208)) +- Bump `@metamask/keyring-snap-client` from `^3.0.0` to `^3.0.3` ([#5190](https://github.com/MetaMask/core/pull/5190)), ([#5208](https://github.com/MetaMask/core/pull/5208)) + ## [46.0.1] ### Changed @@ -1338,7 +1405,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@46.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@50.0.0...HEAD +[50.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@49.0.0...@metamask/assets-controllers@50.0.0 +[49.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@48.0.0...@metamask/assets-controllers@49.0.0 +[48.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@47.0.0...@metamask/assets-controllers@48.0.0 +[47.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@46.0.1...@metamask/assets-controllers@47.0.0 [46.0.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@46.0.0...@metamask/assets-controllers@46.0.1 [46.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@45.1.2...@metamask/assets-controllers@46.0.0 [45.1.2]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@45.1.1...@metamask/assets-controllers@45.1.2 diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index acde5f8d16a..ea8b3cd4ba8 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controllers", - "version": "46.0.1", + "version": "50.0.0", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "MetaMask", @@ -54,21 +54,21 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/abi-utils": "^2.0.3", - "@metamask/base-controller": "^7.1.1", + "@metamask/base-controller": "^8.0.0", "@metamask/contract-metadata": "^2.4.0", - "@metamask/controller-utils": "^11.4.5", + "@metamask/controller-utils": "^11.5.0", "@metamask/eth-query": "^4.0.0", + "@metamask/keyring-api": "^17.0.0", "@metamask/metamask-eth-abis": "^3.1.1", - "@metamask/polling-controller": "^12.0.2", + "@metamask/polling-controller": "^12.0.3", "@metamask/rpc-errors": "^7.0.2", - "@metamask/snaps-utils": "^8.3.0", - "@metamask/utils": "^11.0.1", + "@metamask/snaps-utils": "^8.10.0", + "@metamask/utils": "^11.1.0", "@types/bn.js": "^5.1.5", "@types/uuid": "^8.3.0", "async-mutex": "^0.5.0", "bitcoin-address-validation": "^2.2.3", "bn.js": "^5.2.1", - "cockatiel": "^3.1.2", "immer": "^9.0.6", "lodash": "^4.17.21", "multiformats": "^13.1.0", @@ -77,19 +77,19 @@ }, "devDependencies": { "@babel/runtime": "^7.23.9", - "@metamask/accounts-controller": "^21.0.2", - "@metamask/approval-controller": "^7.1.2", + "@metamask/accounts-controller": "^24.0.0", + "@metamask/approval-controller": "^7.1.3", "@metamask/auto-changelog": "^3.4.4", "@metamask/ethjs-provider-http": "^0.3.0", - "@metamask/keyring-api": "^14.0.0", - "@metamask/keyring-controller": "^19.0.4", - "@metamask/keyring-internal-api": "^2.0.1", - "@metamask/keyring-snap-client": "^3.0.0", - "@metamask/network-controller": "^22.1.1", - "@metamask/preferences-controller": "^15.0.1", + "@metamask/keyring-controller": "^19.1.0", + "@metamask/keyring-internal-api": "^4.0.1", + "@metamask/keyring-snap-client": "^3.0.3", + "@metamask/network-controller": "^22.2.1", + "@metamask/permission-controller": "^11.0.6", + "@metamask/preferences-controller": "^15.0.2", "@metamask/providers": "^18.1.1", - "@metamask/snaps-controllers": "^9.10.0", - "@metamask/snaps-sdk": "^6.7.0", + "@metamask/snaps-controllers": "^9.19.0", + "@metamask/snaps-sdk": "^6.17.1", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.191", "@types/node": "^16.18.54", @@ -105,12 +105,14 @@ "webextension-polyfill": "^0.12.0" }, "peerDependencies": { - "@metamask/accounts-controller": "^21.0.0", + "@metamask/accounts-controller": "^24.0.0", "@metamask/approval-controller": "^7.0.0", "@metamask/keyring-controller": "^19.0.0", "@metamask/network-controller": "^22.0.0", + "@metamask/permission-controller": "^11.0.0", "@metamask/preferences-controller": "^15.0.0", "@metamask/providers": "^18.1.0", + "@metamask/snaps-controllers": "^9.19.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 8b82b7b4c6e..78787be4e79 100644 --- a/packages/assets-controllers/src/AccountTrackerController.test.ts +++ b/packages/assets-controllers/src/AccountTrackerController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { query, toChecksumHexAddress } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { @@ -9,6 +9,12 @@ import { import { getDefaultPreferencesState } from '@metamask/preferences-controller'; import * as sinon from 'sinon'; +import type { + AccountTrackerControllerMessenger, + AllowedActions, + AllowedEvents, +} from './AccountTrackerController'; +import { AccountTrackerController } from './AccountTrackerController'; import { advanceTime } from '../../../tests/helpers'; import { createMockInternalAccount } from '../../accounts-controller/src/tests/mocks'; import type { @@ -19,12 +25,6 @@ import { buildCustomNetworkClientConfiguration, buildMockGetNetworkClientById, } from '../../network-controller/tests/helpers'; -import type { - AccountTrackerControllerMessenger, - AllowedActions, - AllowedEvents, -} from './AccountTrackerController'; -import { AccountTrackerController } from './AccountTrackerController'; jest.mock('@metamask/controller-utils', () => { return { @@ -95,12 +95,6 @@ describe('AccountTrackerController', () => { }); describe('refresh', () => { - beforeEach(() => { - jest - .spyOn(AccountTrackerController.prototype, 'poll') - .mockImplementationOnce(async () => Promise.resolve()); - }); - describe('without networkClientId', () => { it('should sync addresses', async () => { const mockAddress1 = '0xbabe9bbeab5f83a755ac92c7a09b9ab3ff527f8c'; @@ -787,8 +781,11 @@ describe('AccountTrackerController', () => { }); }); - it('should call refresh every interval on legacy polling', async () => { - const pollSpy = jest.spyOn(AccountTrackerController.prototype, 'poll'); + it('should call refresh every interval on polling', async () => { + const pollSpy = jest.spyOn( + AccountTrackerController.prototype, + '_executePoll', + ); await withController( { options: { interval: 100 }, @@ -799,6 +796,11 @@ describe('AccountTrackerController', () => { async ({ controller }) => { jest.spyOn(controller, 'refresh').mockResolvedValue(); + await controller.startPolling({ + networkClientId: 'networkClientId1', + }); + await advanceTime({ clock, duration: 1 }); + expect(pollSpy).toHaveBeenCalledTimes(1); await advanceTime({ clock, duration: 50 }); @@ -813,7 +815,6 @@ describe('AccountTrackerController', () => { }); it('should call refresh every interval for each networkClientId being polled', async () => { - jest.spyOn(AccountTrackerController.prototype, 'poll').mockResolvedValue(); const networkClientId1 = 'networkClientId1'; const networkClientId2 = 'networkClientId2'; await withController( @@ -867,6 +868,27 @@ describe('AccountTrackerController', () => { }, ); }); + + it('should not call polling twice', async () => { + await withController( + { + options: { interval: 100 }, + }, + async ({ controller }) => { + const refreshSpy = jest + .spyOn(controller, 'refresh') + .mockResolvedValue(); + + expect(refreshSpy).not.toHaveBeenCalled(); + controller.startPolling({ + networkClientId: 'networkClientId1', + }); + + await advanceTime({ clock, duration: 1 }); + expect(refreshSpy).toHaveBeenCalledTimes(1); + }, + ); + }); }); type WithControllerCallback = ({ @@ -911,7 +933,7 @@ async function withController( testFunction, ] = args.length === 2 ? args : [{}, args[0]]; - const messenger = new ControllerMessenger< + const messenger = new Messenger< ExtractAvailableAction | AllowedActions, ExtractAvailableEvent | AllowedEvents >(); diff --git a/packages/assets-controllers/src/AccountTrackerController.ts b/packages/assets-controllers/src/AccountTrackerController.ts index b6ad00396dd..a0133623d02 100644 --- a/packages/assets-controllers/src/AccountTrackerController.ts +++ b/packages/assets-controllers/src/AccountTrackerController.ts @@ -7,7 +7,7 @@ import type { import type { ControllerStateChangeEvent, ControllerGetStateAction, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { query, @@ -119,7 +119,7 @@ export type AllowedEvents = /** * The messenger of the {@link AccountTrackerController}. */ -export type AccountTrackerControllerMessenger = RestrictedControllerMessenger< +export type AccountTrackerControllerMessenger = RestrictedMessenger< typeof controllerName, AccountTrackerControllerActions | AllowedActions, AccountTrackerControllerEvents | AllowedEvents, @@ -146,8 +146,6 @@ export class AccountTrackerController extends StaticIntervalPollingController; - /** * Creates an AccountTracker instance. * @@ -198,10 +196,6 @@ export class AccountTrackerController extends StaticIntervalPollingController { - if (interval) { - this.setIntervalLength(interval); - } - - if (this.#handle) { - clearTimeout(this.#handle); - } - - await this.refresh(); - - 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(this.getIntervalLength()); - }, this.getIntervalLength()); - } - /** * Refreshes the balances of the accounts using the networkClientId * diff --git a/packages/assets-controllers/src/AssetsContractController.test.ts b/packages/assets-controllers/src/AssetsContractController.test.ts index 8df5743c3ef..d2bbb4220a9 100644 --- a/packages/assets-controllers/src/AssetsContractController.test.ts +++ b/packages/assets-controllers/src/AssetsContractController.test.ts @@ -1,5 +1,5 @@ import { BigNumber } from '@ethersproject/bignumber'; -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { BUILT_IN_NETWORKS, ChainId, @@ -78,7 +78,7 @@ async function setupAssetContractControllers({ } as const; let provider: Provider; - const controllerMessenger = new ControllerMessenger< + const messenger = new Messenger< | ExtractAvailableAction | NetworkControllerActions, | ExtractAvailableEvent @@ -86,11 +86,13 @@ async function setupAssetContractControllers({ >(); const networkController = new NetworkController({ infuraProjectId, - messenger: controllerMessenger.getRestricted({ + messenger: messenger.getRestricted({ name: 'NetworkController', allowedActions: [], allowedEvents: [], }), + fetch, + btoa, }); if (useNetworkControllerProvider) { await networkController.initializeProvider(); @@ -103,10 +105,8 @@ async function setupAssetContractControllers({ ); } - controllerMessenger.unregisterActionHandler( - 'NetworkController:getNetworkClientById', - ); - controllerMessenger.registerActionHandler( + messenger.unregisterActionHandler('NetworkController:getNetworkClientById'); + messenger.registerActionHandler( 'NetworkController:getNetworkClientById', // @ts-expect-error TODO: remove this annotation once the `Eip1193Provider` class is released useNetworkControllerProvider @@ -117,7 +117,7 @@ async function setupAssetContractControllers({ }), ); - const assetsContractMessenger = controllerMessenger.getRestricted({ + const assetsContractMessenger = messenger.getRestricted({ name: 'AssetsContractController', allowedActions: [ 'NetworkController:getNetworkClientById', @@ -137,18 +137,14 @@ async function setupAssetContractControllers({ }); return { - messenger: controllerMessenger, + messenger, network: networkController, assetsContract, provider, networkClientConfiguration, infuraProjectId, triggerPreferencesStateChange: (state: PreferencesState) => { - controllerMessenger.publish( - 'PreferencesController:stateChange', - state, - [], - ); + messenger.publish('PreferencesController:stateChange', state, []); }, }; } diff --git a/packages/assets-controllers/src/AssetsContractController.ts b/packages/assets-controllers/src/AssetsContractController.ts index 323d90ead74..5e8a9398d67 100644 --- a/packages/assets-controllers/src/AssetsContractController.ts +++ b/packages/assets-controllers/src/AssetsContractController.ts @@ -4,7 +4,7 @@ import { Contract } from '@ethersproject/contracts'; import { Web3Provider } from '@ethersproject/providers'; import type { ActionConstraint, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { IPFS_DEFAULT_GATEWAY_URL } from '@metamask/controller-utils'; import type { @@ -201,7 +201,7 @@ export type AllowedEvents = /** * The messenger of the {@link AssetsContractController}. */ -export type AssetsContractControllerMessenger = RestrictedControllerMessenger< +export type AssetsContractControllerMessenger = RestrictedMessenger< typeof name, AssetsContractControllerActions | AllowedActions, AssetsContractControllerEvents | AllowedEvents, @@ -229,7 +229,7 @@ export class AssetsContractController { * Creates a AssetsContractController instance. * * @param options - The controller options. - * @param options.messenger - The controller messenger. + * @param options.messenger - The messenger. * @param options.chainId - The chain ID of the current network. */ constructor({ @@ -328,8 +328,8 @@ export class AssetsContractController { `NetworkController:getNetworkClientById`, networkClientId, ).provider - : this.messagingSystem.call('NetworkController:getSelectedNetworkClient') - ?.provider ?? this.#provider; + : (this.messagingSystem.call('NetworkController:getSelectedNetworkClient') + ?.provider ?? this.#provider); if (provider === undefined) { throw new Error(MISSING_PROVIDER_ERROR); diff --git a/packages/assets-controllers/src/CurrencyRateController.test.ts b/packages/assets-controllers/src/CurrencyRateController.test.ts index f22e8c1471b..007afa72e06 100644 --- a/packages/assets-controllers/src/CurrencyRateController.test.ts +++ b/packages/assets-controllers/src/CurrencyRateController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { ChainId, NetworkType, @@ -18,16 +18,16 @@ import { CurrencyRateController } from './CurrencyRateController'; const name = 'CurrencyRateController' as const; /** - * Constructs a restricted controller messenger. + * Constructs a restricted messenger. * - * @returns A restricted controller messenger. + * @returns A restricted messenger. */ function getRestrictedMessenger() { - const controllerMessenger = new ControllerMessenger< + const messenger = new Messenger< GetCurrencyRateState | NetworkControllerGetNetworkClientByIdAction, CurrencyRateStateChange >(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'NetworkController:getNetworkClientById', jest.fn().mockImplementation((networkClientId) => { switch (networkClientId) { @@ -52,7 +52,7 @@ function getRestrictedMessenger() { } }), ); - const messenger = controllerMessenger.getRestricted< + return messenger.getRestricted< typeof name, NetworkControllerGetNetworkClientByIdAction['type'] >({ @@ -60,7 +60,6 @@ function getRestrictedMessenger() { allowedActions: ['NetworkController:getNetworkClientById'], allowedEvents: [], }); - return messenger; } const getStubbedDate = () => { diff --git a/packages/assets-controllers/src/CurrencyRateController.ts b/packages/assets-controllers/src/CurrencyRateController.ts index ccb12282dfc..e43c4fafb99 100644 --- a/packages/assets-controllers/src/CurrencyRateController.ts +++ b/packages/assets-controllers/src/CurrencyRateController.ts @@ -1,5 +1,5 @@ import type { - RestrictedControllerMessenger, + RestrictedMessenger, ControllerGetStateAction, ControllerStateChangeEvent, } from '@metamask/base-controller'; @@ -51,7 +51,7 @@ export type CurrencyRateControllerActions = GetCurrencyRateState; type AllowedActions = NetworkControllerGetNetworkClientByIdAction; -type CurrencyRateMessenger = RestrictedControllerMessenger< +type CurrencyRateMessenger = RestrictedMessenger< typeof name, CurrencyRateControllerActions | AllowedActions, CurrencyRateControllerEvents, diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts new file mode 100644 index 00000000000..d479d5af1e5 --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts @@ -0,0 +1,776 @@ +import { Messenger } from '@metamask/base-controller'; +import type { + AccountAssetListUpdatedEventPayload, + CaipAssetTypeOrId, +} from '@metamask/keyring-api'; +import { + EthAccountType, + EthMethod, + EthScope, + SolScope, +} from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { PermissionConstraint } from '@metamask/permission-controller'; +import type { SubjectPermissions } from '@metamask/permission-controller'; +import type { Snap } from '@metamask/snaps-utils'; +import { useFakeTimers } from 'sinon'; +import { v4 as uuidv4 } from 'uuid'; + +import { + getDefaultMultichainAssetsControllerState, + MultichainAssetsController, +} from '.'; +import type { + AssetMetadataResponse, + MultichainAssetsControllerMessenger, + MultichainAssetsControllerState, +} from './MultichainAssetsController'; +import { advanceTime } from '../../../../tests/helpers'; +import type { + ExtractAvailableAction, + ExtractAvailableEvent, +} from '../../../base-controller/tests/helpers'; + +const mockSolanaAccount: InternalAccount = { + type: 'solana:data-account', + id: 'a3fc6831-d229-4cd1-87c1-13b1756213d4', + address: 'EBBYfhQzVzurZiweJ2keeBWpgGLs1cbWYcz28gjGgi5x', + scopes: [SolScope.Devnet], + options: { + scope: SolScope.Devnet, + }, + methods: ['sendAndConfirmTransaction'], + metadata: { + name: 'Snap Account 1', + importTime: 1737022568097, + keyring: { + type: 'Snap Keyring', + }, + snap: { + id: 'local:http://localhost:8080', + name: 'Solana', + enabled: true, + }, + lastSelected: 0, + }, +}; + +const mockEthAccount: InternalAccount = { + address: '0x807dE1cf8f39E83258904b2f7b473E5C506E4aC1', + id: uuidv4(), + metadata: { + name: 'Ethereum Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-eth-snap', + name: 'mock-eth-snap', + enabled: true, + }, + lastSelected: 0, + }, + scopes: [EthScope.Eoa], + options: {}, + methods: [EthMethod.SignTypedDataV4, EthMethod.SignTransaction], + type: EthAccountType.Eoa, +}; + +const mockHandleRequestOnAssetsLookupReturnValue = [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', +]; + +const mockGetAllSnapsReturnValue = [ + { + blocked: false, + enabled: true, + id: 'local:http://localhost:8080', + version: '1.0.4', + }, + { + blocked: false, + enabled: true, + id: 'npm:@metamask/account-watcher', + version: '4.1.0', + }, + { + blocked: false, + enabled: true, + id: 'npm:@metamask/bitcoin-wallet-snap', + version: '0.8.2', + }, + { + blocked: false, + enabled: true, + id: 'npm:@metamask/ens-resolver-snap', + version: '0.1.2', + }, + { + blocked: false, + enabled: true, + id: 'npm:@metamask/message-signing-snap', + version: '0.6.0', + }, + { + blocked: false, + enabled: true, + id: 'npm:@metamask/preinstalled-example-snap', + version: '0.2.0', + }, + { + blocked: false, + enabled: true, + id: 'npm:@metamask/solana-wallet-snap', + version: '1.0.3', + }, +]; + +const mockGetPermissionsReturnValue = [ + { + 'endowment:assets': { + caveats: [ + { + type: 'chainIds', + value: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'], + }, + ], + }, + }, + { + 'endowment:ethereum-provider': { + caveats: null, + date: 1736868793768, + id: 'CTUx_19iltoLo-xnIjGMc', + invoker: 'npm:@metamask/account-watcher', + parentCapability: 'endowment:ethereum-provider', + }, + }, + { + 'endowment:network-access': { + caveats: null, + date: 1736868793769, + id: '9NST-8ZIQO7_BVVJP6JyD', + invoker: 'npm:@metamask/bitcoin-wallet-snap', + parentCapability: 'endowment:network-access', + }, + }, + { + 'endowment:ethereum-provider': { + caveats: null, + date: 1736868793767, + id: '8cUIGf_BjDke2xJSn_kBL', + invoker: 'npm:@metamask/ens-resolver-snap', + parentCapability: 'endowment:ethereum-provider', + }, + }, + { + 'endowment:rpc': { + date: 1736868793765, + id: 'j8XfK-fPq13COl7xFQxXn', + invoker: 'npm:@metamask/message-signing-snap', + parentCapability: 'endowment:rpc', + }, + }, + { + 'endowment:rpc': { + date: 1736868793771, + id: 'Yd155j5BoXh3BIndgMkAM', + invoker: 'npm:@metamask/preinstalled-example-snap', + parentCapability: 'endowment:rpc', + }, + }, + { + 'endowment:network-access': { + caveats: null, + date: 1736868793773, + id: 'HbXb8MLHbRrQMexyVpQQ7', + invoker: 'npm:@metamask/solana-wallet-snap', + parentCapability: 'endowment:network-access', + }, + }, +]; + +const mockGetMetadataReturnValue: AssetMetadataResponse | undefined = { + assets: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501': { + name: 'Solana', + symbol: 'SOL', + fungible: true, + iconUrl: 'url1', + units: [{ name: 'Solana', symbol: 'SOL', decimals: 9 }], + }, + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr': + { + name: 'USDC', + symbol: 'USDC', + fungible: true, + iconUrl: 'url2', + units: [{ name: 'USDC', symbol: 'SUSDCOL', decimals: 18 }], + }, + }, +}; + +/** + * The union of actions that the root messenger allows. + */ +type RootAction = ExtractAvailableAction; + +/** + * The union of events that the root messenger allows. + */ +type RootEvent = ExtractAvailableEvent; + +/** + * Constructs the unrestricted messenger. This can be used to call actions and + * publish events within the tests for this controller. + * + * @returns The unrestricted messenger suited for PetNamesController. + */ +function getRootMessenger(): Messenger { + return new Messenger(); +} + +const setupController = ({ + state = getDefaultMultichainAssetsControllerState(), + mocks, +}: { + state?: MultichainAssetsControllerState; + mocks?: { + listMultichainAccounts?: InternalAccount[]; + handleRequestReturnValue?: CaipAssetTypeOrId[]; + getAllReturnValue?: Snap[]; + getPermissionsReturnValue?: SubjectPermissions; + }; +} = {}) => { + const messenger = getRootMessenger(); + + const multichainAssetsControllerMessenger: MultichainAssetsControllerMessenger = + messenger.getRestricted({ + name: 'MultichainAssetsController', + allowedActions: [ + 'AccountsController:listMultichainAccounts', + 'SnapController:handleRequest', + 'SnapController:getAll', + 'PermissionController:getPermissions', + ], + allowedEvents: [ + 'AccountsController:accountAdded', + 'AccountsController:accountRemoved', + 'AccountsController:accountAssetListUpdated', + ], + }); + + const mockSnapHandleRequest = jest.fn(); + messenger.registerActionHandler( + 'SnapController:handleRequest', + mockSnapHandleRequest.mockReturnValue( + mocks?.handleRequestReturnValue ?? + mockHandleRequestOnAssetsLookupReturnValue, + ), + ); + + const mockListMultichainAccounts = jest.fn(); + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + mockListMultichainAccounts.mockReturnValue( + mocks?.listMultichainAccounts ?? [mockSolanaAccount, mockEthAccount], + ), + ); + + const mockGetAllSnaps = jest.fn(); + messenger.registerActionHandler( + 'SnapController:getAll', + mockGetAllSnaps.mockReturnValue( + mocks?.getAllReturnValue ?? mockGetAllSnapsReturnValue, + ), + ); + + const mockGetPermissions = jest.fn(); + messenger.registerActionHandler( + 'PermissionController:getPermissions', + mockGetPermissions.mockReturnValue( + mocks?.getPermissionsReturnValue ?? mockGetPermissionsReturnValue[0], + ), + ); + + const controller = new MultichainAssetsController({ + messenger: multichainAssetsControllerMessenger, + state, + }); + + return { + controller, + messenger, + mockSnapHandleRequest, + mockListMultichainAccounts, + mockGetAllSnaps, + mockGetPermissions, + }; +}; + +describe('MultichainAssetsController', () => { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + it('initialize with default state', () => { + const { controller } = setupController({}); + expect(controller.state).toStrictEqual({ + accountsAssets: {}, + assetsMetadata: {}, + }); + }); + + it('does not update state when new account added is EVM', async () => { + const { controller, messenger } = setupController(); + + messenger.publish( + 'AccountsController:accountAdded', + mockEthAccount as unknown as InternalAccount, + ); + + await advanceTime({ clock, duration: 1 }); + + expect(controller.state).toStrictEqual({ + accountsAssets: {}, + assetsMetadata: {}, + }); + }); + + it('updates accountsAssets when "AccountsController:accountAdded" is fired', async () => { + const { controller, messenger, mockSnapHandleRequest, mockGetPermissions } = + setupController(); + + mockSnapHandleRequest + .mockReturnValueOnce(mockHandleRequestOnAssetsLookupReturnValue) + .mockReturnValueOnce(mockGetMetadataReturnValue); + + mockGetPermissions + .mockReturnValueOnce(mockGetPermissionsReturnValue[0]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[1]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[2]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[3]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[4]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[5]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[6]); + + messenger.publish( + 'AccountsController:accountAdded', + mockSolanaAccount as unknown as InternalAccount, + ); + + await advanceTime({ clock, duration: 1 }); + + expect(controller.state).toStrictEqual({ + accountsAssets: { + [mockSolanaAccount.id]: mockHandleRequestOnAssetsLookupReturnValue, + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + }); + }); + + it('updates metadata in state successfully when all calls succeed to fetch metadata', async () => { + const { controller, messenger, mockSnapHandleRequest, mockGetPermissions } = + setupController(); + + const mockHandleRequestOnAssetsLookupResponse = [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]; + const mockSnapPermissionReturnVal = { + 'endowment:assets': { + caveats: [ + { + type: 'chainIds', + value: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ], + }, + ], + }, + }; + const mockGetMetadataResponse: AssetMetadataResponse | undefined = { + assets: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + name: 'Solana2', + symbol: 'SOL', + fungible: true, + iconUrl: 'url1', + units: [{ name: 'Solana2', symbol: 'SOL', decimals: 9 }], + }, + }, + }; + + mockSnapHandleRequest + .mockReturnValueOnce(mockHandleRequestOnAssetsLookupResponse) + .mockReturnValueOnce(mockGetMetadataReturnValue) + .mockReturnValueOnce(mockGetMetadataResponse); + + mockGetPermissions + .mockReturnValueOnce(mockSnapPermissionReturnVal) + .mockReturnValueOnce(mockGetPermissionsReturnValue[1]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[2]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[3]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[4]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[5]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[6]); + + messenger.publish( + 'AccountsController:accountAdded', + mockSolanaAccount as unknown as InternalAccount, + ); + + await advanceTime({ clock, duration: 1 }); + + expect(mockSnapHandleRequest).toHaveBeenCalledTimes(3); + + expect(controller.state).toStrictEqual({ + accountsAssets: { + [mockSolanaAccount.id]: mockHandleRequestOnAssetsLookupResponse, + }, + assetsMetadata: { + ...mockGetMetadataResponse.assets, + ...mockGetMetadataReturnValue.assets, + }, + }); + }); + + it('updates metadata in state successfully when one call to fetch metadata fails', async () => { + const { controller, messenger, mockSnapHandleRequest, mockGetPermissions } = + setupController(); + + const mockHandleRequestOnAssetsLookupResponse = [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]; + const mockSnapPermissionReturnVal = { + 'endowment:assets': { + caveats: [ + { + type: 'chainIds', + value: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ], + }, + ], + }, + }; + + mockSnapHandleRequest + .mockReturnValueOnce(mockHandleRequestOnAssetsLookupResponse) + .mockReturnValueOnce(mockGetMetadataReturnValue) + .mockRejectedValueOnce('Error'); + + mockGetPermissions + .mockReturnValueOnce(mockSnapPermissionReturnVal) + .mockReturnValueOnce(mockGetPermissionsReturnValue[1]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[2]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[3]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[4]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[5]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[6]); + + messenger.publish( + 'AccountsController:accountAdded', + mockSolanaAccount as unknown as InternalAccount, + ); + + await advanceTime({ clock, duration: 1 }); + + expect(mockSnapHandleRequest).toHaveBeenCalledTimes(3); + + expect(controller.state).toStrictEqual({ + accountsAssets: { + [mockSolanaAccount.id]: mockHandleRequestOnAssetsLookupResponse, + }, + assetsMetadata: { + ...mockGetMetadataReturnValue.assets, + }, + }); + }); + + it('does not delete account from accountsAssets when "AccountsController:accountRemoved" is fired with EVM account', async () => { + const { controller, messenger, mockSnapHandleRequest, mockGetPermissions } = + setupController(); + + mockSnapHandleRequest + .mockReturnValueOnce(mockHandleRequestOnAssetsLookupReturnValue) + .mockReturnValueOnce(mockGetMetadataReturnValue); + + mockGetPermissions + .mockReturnValueOnce(mockGetPermissionsReturnValue[0]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[1]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[2]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[3]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[4]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[5]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[6]); + + // Add a solana account first + messenger.publish( + 'AccountsController:accountAdded', + mockSolanaAccount as unknown as InternalAccount, + ); + + await advanceTime({ clock, duration: 1 }); + + expect(controller.state).toStrictEqual({ + accountsAssets: { + [mockSolanaAccount.id]: mockHandleRequestOnAssetsLookupReturnValue, + }, + + assetsMetadata: mockGetMetadataReturnValue.assets, + }); + // Remove an EVM account + messenger.publish('AccountsController:accountRemoved', mockEthAccount.id); + + await advanceTime({ clock, duration: 1 }); + + expect(controller.state).toStrictEqual({ + accountsAssets: { + [mockSolanaAccount.id]: mockHandleRequestOnAssetsLookupReturnValue, + }, + + assetsMetadata: mockGetMetadataReturnValue.assets, + }); + }); + + it('updates accountsAssets when "AccountsController:accountRemoved" is fired', async () => { + const { controller, messenger, mockSnapHandleRequest, mockGetPermissions } = + setupController(); + + mockSnapHandleRequest + .mockReturnValueOnce(mockHandleRequestOnAssetsLookupReturnValue) + .mockReturnValueOnce(mockGetMetadataReturnValue); + + mockGetPermissions + .mockReturnValueOnce(mockGetPermissionsReturnValue[0]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[1]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[2]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[3]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[4]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[5]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[6]); + + // Add a solana account first + messenger.publish( + 'AccountsController:accountAdded', + mockSolanaAccount as unknown as InternalAccount, + ); + + await advanceTime({ clock, duration: 1 }); + + expect(controller.state).toStrictEqual({ + accountsAssets: { + [mockSolanaAccount.id]: mockHandleRequestOnAssetsLookupReturnValue, + }, + + assetsMetadata: mockGetMetadataReturnValue.assets, + }); + // Remove the added solana account + messenger.publish( + 'AccountsController:accountRemoved', + mockSolanaAccount.id, + ); + + await advanceTime({ clock, duration: 1 }); + + expect(controller.state).toStrictEqual({ + accountsAssets: {}, + + assetsMetadata: mockGetMetadataReturnValue.assets, + }); + }); + + describe('handleAccountAssetListUpdated', () => { + it('updates the assets list for an account when a new asset is added', async () => { + const mockSolanaAccountId1 = 'account1'; + const mockSolanaAccountId2 = 'account2'; + const { + messenger, + controller, + mockSnapHandleRequest, + mockGetPermissions, + } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccountId1]: mockHandleRequestOnAssetsLookupReturnValue, + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + } as MultichainAssetsControllerState, + }); + + const mockGetMetadataReturnValue1 = { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken': { + name: 'newToken', + symbol: 'newToken', + decimals: 18, + }, + }; + const mockGetMetadataReturnValue2 = { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3': { + name: 'newToken3', + symbol: 'newToken3', + decimals: 18, + }, + }; + mockSnapHandleRequest.mockReturnValue({ + assets: { + ...mockGetMetadataReturnValue1, + ...mockGetMetadataReturnValue2, + }, + }); + + mockGetPermissions + .mockReturnValueOnce(mockGetPermissionsReturnValue[0]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[1]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[2]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[3]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[4]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[5]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[6]); + const updatedAssetsList: AccountAssetListUpdatedEventPayload = { + assets: { + [mockSolanaAccountId1]: { + added: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken'], + removed: [], + }, + [mockSolanaAccountId2]: { + added: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3'], + removed: [], + }, + }, + }; + + messenger.publish( + 'AccountsController:accountAssetListUpdated', + updatedAssetsList, + ); + + await advanceTime({ clock, duration: 1 }); + + expect(controller.state.accountsAssets).toStrictEqual({ + [mockSolanaAccountId1]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken', + ], + [mockSolanaAccountId2]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3', + ], + }); + + expect(mockSnapHandleRequest).toHaveBeenCalledTimes(1); + + expect(controller.state.assetsMetadata).toStrictEqual({ + ...mockGetMetadataReturnValue.assets, + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken': { + name: 'newToken', + symbol: 'newToken', + decimals: 18, + }, + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3': { + name: 'newToken3', + symbol: 'newToken3', + decimals: 18, + }, + }); + }); + + it('does not add duplicate assets to state', async () => { + const mockSolanaAccountId1 = 'account1'; + const mockSolanaAccountId2 = 'account2'; + const { controller, messenger } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccountId1]: mockHandleRequestOnAssetsLookupReturnValue, + }, + assetsMetadata: mockGetMetadataReturnValue, + } as MultichainAssetsControllerState, + }); + + const updatedAssetsList: AccountAssetListUpdatedEventPayload = { + assets: { + [mockSolanaAccountId1]: { + added: + mockHandleRequestOnAssetsLookupReturnValue as `${string}:${string}/${string}:${string}`[], + removed: [], + }, + [mockSolanaAccountId2]: { + added: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3'], + removed: [], + }, + }, + }; + + messenger.publish( + 'AccountsController:accountAssetListUpdated', + updatedAssetsList, + ); + await advanceTime({ clock, duration: 1 }); + + expect(controller.state.accountsAssets).toStrictEqual({ + [mockSolanaAccountId1]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + ], + [mockSolanaAccountId2]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3', + ], + }); + }); + + it('updates the assets list for an account when a an asset is removed', async () => { + const mockSolanaAccountId1 = 'account1'; + const mockSolanaAccountId2 = 'account2'; + const { controller, messenger } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccountId1]: mockHandleRequestOnAssetsLookupReturnValue, + [mockSolanaAccountId2]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3', + ], + }, + assetsMetadata: mockGetMetadataReturnValue, + } as MultichainAssetsControllerState, + }); + + const updatedAssetsList: AccountAssetListUpdatedEventPayload = { + assets: { + [mockSolanaAccountId2]: { + added: [], + removed: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3', + ], + }, + }, + }; + + messenger.publish( + 'AccountsController:accountAssetListUpdated', + updatedAssetsList, + ); + await advanceTime({ clock, duration: 1 }); + + expect(controller.state.accountsAssets).toStrictEqual({ + [mockSolanaAccountId1]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + ], + [mockSolanaAccountId2]: [], + }); + }); + }); +}); diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts new file mode 100644 index 00000000000..288a65472f6 --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts @@ -0,0 +1,562 @@ +import type { + AccountsControllerAccountAddedEvent, + AccountsControllerAccountAssetListUpdatedEvent, + AccountsControllerAccountRemovedEvent, + AccountsControllerListMultichainAccountsAction, +} from '@metamask/accounts-controller'; +import { + BaseController, + type ControllerGetStateAction, + type ControllerStateChangeEvent, + type RestrictedMessenger, +} from '@metamask/base-controller'; +import { isEvmAccountType } from '@metamask/keyring-api'; +import type { + AccountAssetListUpdatedEventPayload, + CaipAssetType, + CaipAssetTypeOrId, +} from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { KeyringClient } from '@metamask/keyring-snap-client'; +import type { + GetPermissions, + PermissionConstraint, + SubjectPermissions, +} from '@metamask/permission-controller'; +import type { + GetAllSnaps, + HandleSnapRequest, +} from '@metamask/snaps-controllers'; +import type { FungibleAssetMetadata, Snap, SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import { + hasProperty, + isCaipAssetType, + parseCaipAssetType, + type CaipChainId, +} from '@metamask/utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; +import type { MutexInterface } from 'async-mutex'; +import { Mutex } from 'async-mutex'; + +import { getChainIdsCaveat } from './utils'; + +const controllerName = 'MultichainAssetsController'; + +export type MultichainAssetsControllerState = { + assetsMetadata: { + [asset: CaipAssetType]: FungibleAssetMetadata; + }; + accountsAssets: { [account: string]: CaipAssetType[] }; +}; + +// Represents the response of the asset snap's onAssetLookup handler +export type AssetMetadataResponse = { + assets: { + [asset: CaipAssetType]: FungibleAssetMetadata; + }; +}; + +/** + * Constructs the default {@link MultichainAssetsController} 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 MultichainAssetsController} state. + */ +export function getDefaultMultichainAssetsControllerState(): MultichainAssetsControllerState { + return { accountsAssets: {}, assetsMetadata: {} }; +} + +/** + * Returns the state of the {@link MultichainAssetsController}. + */ +export type MultichainAssetsControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + MultichainAssetsControllerState +>; + +/** + * Event emitted when the state of the {@link MultichainAssetsController} changes. + */ +export type MultichainAssetsControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + MultichainAssetsControllerState + >; + +/** + * Actions exposed by the {@link MultichainAssetsController}. + */ +export type MultichainAssetsControllerActions = + MultichainAssetsControllerGetStateAction; + +/** + * Events emitted by {@link MultichainAssetsController}. + */ +export type MultichainAssetsControllerEvents = + MultichainAssetsControllerStateChangeEvent; + +/** + * A function executed within a mutually exclusive lock, with + * a mutex releaser in its option bag. + * + * @param releaseLock - A function to release the lock. + */ +type MutuallyExclusiveCallback = ({ + releaseLock, +}: { + releaseLock: MutexInterface.Releaser; +}) => Promise; + +/** + * Actions that this controller is allowed to call. + */ +type AllowedActions = + | HandleSnapRequest + | GetAllSnaps + | GetPermissions + | AccountsControllerListMultichainAccountsAction; + +/** + * Events that this controller is allowed to subscribe. + */ +type AllowedEvents = + | AccountsControllerAccountAddedEvent + | AccountsControllerAccountRemovedEvent + | AccountsControllerAccountAssetListUpdatedEvent; + +/** + * Messenger type for the MultichainAssetsController. + */ +export type MultichainAssetsControllerMessenger = RestrictedMessenger< + typeof controllerName, + MultichainAssetsControllerActions | AllowedActions, + MultichainAssetsControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + +/** + * {@link MultichainAssetsController}'s metadata. + * + * This allows us to choose if fields of the state should be persisted or not + * using the `persist` flag; and if they can be sent to Sentry or not, using + * the `anonymous` flag. + */ +const assetsControllerMetadata = { + assetsMetadata: { + persist: true, + anonymous: false, + }, + accountsAssets: { + persist: true, + anonymous: false, + }, +}; + +// TODO: make this controller extends StaticIntervalPollingController and update all assetsMetadata once a day. + +export class MultichainAssetsController extends BaseController< + typeof controllerName, + MultichainAssetsControllerState, + MultichainAssetsControllerMessenger +> { + // Mapping of CAIP-2 Chain ID to Asset Snaps. + #snaps: Record; + + readonly #controllerOperationMutex = new Mutex(); + + constructor({ + messenger, + state = {}, + }: { + messenger: MultichainAssetsControllerMessenger; + state?: Partial; + }) { + super({ + messenger, + name: controllerName, + metadata: assetsControllerMetadata, + state: { + ...getDefaultMultichainAssetsControllerState(), + ...state, + }, + }); + + this.#snaps = {}; + + this.messagingSystem.subscribe( + 'AccountsController:accountAdded', + async (account) => await this.#handleOnAccountAddedEvent(account), + ); + this.messagingSystem.subscribe( + 'AccountsController:accountRemoved', + async (account) => await this.#handleOnAccountRemovedEvent(account), + ); + this.messagingSystem.subscribe( + 'AccountsController:accountAssetListUpdated', + async (event) => await this.#handleAccountAssetListUpdatedEvent(event), + ); + } + + async #handleAccountAssetListUpdatedEvent( + event: AccountAssetListUpdatedEventPayload, + ) { + return this.#withControllerLock(async () => + this.#handleAccountAssetListUpdated(event), + ); + } + + async #handleOnAccountAddedEvent(account: InternalAccount) { + return this.#withControllerLock(async () => + this.#handleOnAccountAdded(account), + ); + } + + /** + * Function to update the assets list for an account + * + * @param event - The list of assets to update + */ + async #handleAccountAssetListUpdated( + event: AccountAssetListUpdatedEventPayload, + ) { + this.#assertControllerMutexIsLocked(); + + const assetsToUpdate = event.assets; + let assetsForMetadataRefresh = new Set([]); + for (const accountId in assetsToUpdate) { + if (hasProperty(assetsToUpdate, accountId)) { + const { added, removed } = assetsToUpdate[accountId]; + if (added.length > 0 || removed.length > 0) { + const existing = this.state.accountsAssets[accountId] || []; + const assets = new Set([ + ...existing, + ...added.filter((asset) => isCaipAssetType(asset)), + ]); + for (const removedAsset of removed) { + assets.delete(removedAsset); + } + assetsForMetadataRefresh = new Set([ + ...assetsForMetadataRefresh, + ...assets, + ]); + this.update((state) => { + state.accountsAssets[accountId] = Array.from(assets); + }); + } + } + } + // Trigger fetching metadata for new assets + await this.#refreshAssetsMetadata(Array.from(assetsForMetadataRefresh)); + } + + /** + * Checks for non-EVM accounts. + * + * @param account - The new account to be checked. + * @returns True if the account is a non-EVM account, false otherwise. + */ + #isNonEvmAccount(account: InternalAccount): boolean { + return ( + !isEvmAccountType(account.type) && + // Non-EVM accounts are backed by a Snap for now + account.metadata.snap !== undefined + ); + } + + /** + * Handles changes when a new account has been added. + * + * @param account - The new account being added. + */ + async #handleOnAccountAdded(account: InternalAccount): Promise { + if (!this.#isNonEvmAccount(account)) { + // Nothing to do here for EVM accounts + return; + } + this.#assertControllerMutexIsLocked(); + + // Get assets list + if (account.metadata.snap) { + const assets = await this.#getAssetsList( + account.id, + account.metadata.snap.id, + ); + await this.#refreshAssetsMetadata(assets); + this.update((state) => { + state.accountsAssets[account.id] = assets; + }); + } + } + + /** + * Handles changes when a new account has been removed. + * + * @param accountId - The new account id being removed. + */ + async #handleOnAccountRemovedEvent(accountId: string): Promise { + // Check if accountId is in accountsAssets and if it is, remove it + if (this.state.accountsAssets[accountId]) { + this.update((state) => { + // TODO: We are not deleting the assetsMetadata because we will soon make this controller extends StaticIntervalPollingController + // and update all assetsMetadata once a day. + delete state.accountsAssets[accountId]; + }); + } + } + + /** + * Refreshes the assets snaps and metadata for the given list of assets + * + * @param assets - The assets to refresh + */ + async #refreshAssetsMetadata(assets: CaipAssetType[]) { + this.#assertControllerMutexIsLocked(); + + const assetsWithoutMetadata: CaipAssetType[] = assets.filter( + (asset) => !this.state.assetsMetadata[asset], + ); + + // Call the snap to get the metadata + if (assetsWithoutMetadata.length > 0) { + // Check if for every asset in assetsWithoutMetadata there is a snap in snaps by chainId else call getAssetSnaps + if ( + !assetsWithoutMetadata.every((asset: CaipAssetType) => { + const { chainId } = parseCaipAssetType(asset); + return Boolean(this.#getAssetSnapFor(chainId)); + }) + ) { + this.#snaps = this.#getAssetSnaps(); + } + await this.#updateAssetsMetadata(assetsWithoutMetadata); + } + } + + /** + * Updates the assets metadata for the given list of assets + * + * @param assets - The assets to update + */ + async #updateAssetsMetadata(assets: CaipAssetType[]) { + // Creates a mapping of scope to their respective assets list. + const assetsByScope: Record = {}; + for (const asset of assets) { + const { chainId } = parseCaipAssetType(asset); + if (!assetsByScope[chainId]) { + assetsByScope[chainId] = []; + } + assetsByScope[chainId].push(asset); + } + + let newMetadata: Record = {}; + for (const chainId of Object.keys(assetsByScope) as CaipChainId[]) { + const assetsForChain = assetsByScope[chainId]; + // Now fetch metadata from the associated asset Snaps: + const snap = this.#getAssetSnapFor(chainId); + if (snap) { + const metadata = await this.#getAssetsMetadataFrom( + assetsForChain, + snap.id, + ); + newMetadata = { + ...newMetadata, + ...(metadata?.assets ?? {}), + }; + } + } + this.update((state) => { + state.assetsMetadata = { + ...this.state.assetsMetadata, + ...newMetadata, + }; + }); + } + + /** + * Creates a mapping of CAIP-2 Chain ID to Asset Snaps. + * + * @returns A mapping of CAIP-2 Chain ID to Asset Snaps. + */ + #getAssetSnaps(): Record { + const snaps: Record = {}; + const allSnaps = this.#getAllSnaps(); + const allPermissions = allSnaps.map((snap) => + this.#getSnapsPermissions(snap.id), + ); + + for (const [index, permission] of allPermissions.entries()) { + let scopes; + for (const singlePermissionConstraint of Object.values(permission)) { + scopes = getChainIdsCaveat(singlePermissionConstraint); + if (!scopes) { + continue; + } + for (const scope of scopes as CaipChainId[]) { + if (!snaps[scope]) { + snaps[scope] = []; + } + snaps[scope].push(allSnaps[index]); + } + } + } + return snaps; + } + + /** + * Returns the first asset snap for the given scope + * + * @param scope - The scope to get the asset snap for + * @returns The asset snap for the given scope + */ + #getAssetSnapFor(scope: CaipChainId): Snap | undefined { + const allSnaps = this.#snaps[scope]; + // Pick only the first one, we ignore the other Snaps if there are multiple candidates for now. + return allSnaps?.[0]; // Will be undefined if there's no Snaps candidate for this scope. + } + + /** + * Returns all the asset snaps + * + * @returns All the asset snaps + */ + #getAllSnaps(): Snap[] { + // TODO: Use dedicated SnapController's action once available for this: + return this.messagingSystem + .call('SnapController:getAll') + .filter((snap) => snap.enabled && !snap.blocked); + } + + /** + * Returns the permissions for the given origin + * + * @param origin - The origin to get the permissions for + * @returns The permissions for the given origin + */ + #getSnapsPermissions( + origin: string, + ): SubjectPermissions { + return this.messagingSystem.call( + 'PermissionController:getPermissions', + origin, + ) as SubjectPermissions; + } + + /** + * Returns the metadata for the given assets + * + * @param assets - The assets to get metadata for + * @param snapId - The snap ID to get metadata from + * @returns The metadata for the assets + */ + async #getAssetsMetadataFrom( + assets: CaipAssetType[], + snapId: string, + ): Promise { + try { + return (await this.messagingSystem.call('SnapController:handleRequest', { + snapId: snapId as SnapId, + origin: 'metamask', + handler: HandlerType.OnAssetsLookup, + request: { + jsonrpc: '2.0', + method: 'onAssetLookup', + params: { + assets, + }, + }, + })) as Promise; + } catch (error) { + // Ignore + console.error(error); + return undefined; + } + } + + /** + * Get assets list for an account + * + * @param accountId - AccountId to get assets for + * @param snapId - Snap ID for the account + * @returns list of assets + */ + async #getAssetsList( + accountId: string, + snapId: string, + ): Promise { + return await this.#getClient(snapId).listAccountAssets(accountId); + } + + /** + * Gets a `KeyringClient` for a Snap. + * + * @param snapId - ID of the Snap to get the client for. + * @returns A `KeyringClient` for the Snap. + */ + #getClient(snapId: string): KeyringClient { + return new KeyringClient({ + send: async (request: JsonRpcRequest) => + (await this.messagingSystem.call('SnapController:handleRequest', { + snapId: snapId as SnapId, + origin: 'metamask', + handler: HandlerType.OnKeyringRequest, + request, + })) as Promise, + }); + } + + /** + * Assert that the controller mutex is locked. + * + * @throws If the controller mutex is not locked. + */ + #assertControllerMutexIsLocked() { + if (!this.#controllerOperationMutex.isLocked()) { + throw new Error( + 'MultichainAssetsControllerError - Attempt to update state', + ); + } + } + + /** + * Lock the controller mutex before executing the given function, + * and release it after the function is resolved or after an + * error is thrown. + * + * This wrapper ensures that each mutable operation that interacts with the + * controller and that changes its state is executed in a mutually exclusive way, + * preventing unsafe concurrent access that could lead to unpredictable behavior. + * + * @param callback - The function to execute while the controller mutex is locked. + * @returns The result of the function. + */ + async #withControllerLock( + callback: MutuallyExclusiveCallback, + ): Promise { + return withLock(this.#controllerOperationMutex, callback); + } +} + +/** + * Lock the given mutex before executing the given function, + * and release it after the function is resolved or after an + * error is thrown. + * + * @param mutex - The mutex to lock. + * @param callback - The function to execute while the mutex is locked. + * @returns The result of the function. + */ +async function withLock( + mutex: Mutex, + callback: MutuallyExclusiveCallback, +): Promise { + const releaseLock = await mutex.acquire(); + + try { + return await callback({ releaseLock }); + } finally { + releaseLock(); + } +} diff --git a/packages/assets-controllers/src/MultichainAssetsController/index.ts b/packages/assets-controllers/src/MultichainAssetsController/index.ts new file mode 100644 index 00000000000..a558a58720d --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsController/index.ts @@ -0,0 +1,13 @@ +export { + MultichainAssetsController, + getDefaultMultichainAssetsControllerState, +} from './MultichainAssetsController'; + +export type { + MultichainAssetsControllerState, + MultichainAssetsControllerGetStateAction, + MultichainAssetsControllerStateChangeEvent, + MultichainAssetsControllerActions, + MultichainAssetsControllerEvents, + MultichainAssetsControllerMessenger, +} from './MultichainAssetsController'; diff --git a/packages/assets-controllers/src/MultichainAssetsController/utils.ts b/packages/assets-controllers/src/MultichainAssetsController/utils.ts new file mode 100644 index 00000000000..1b7e2323341 --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsController/utils.ts @@ -0,0 +1,32 @@ +import type { + Caveat, + PermissionConstraint, +} from '@metamask/permission-controller'; +import { SnapCaveatType } from '@metamask/snaps-utils'; + +// TODO: this is a duplicate of https://github.com/MetaMask/snaps/blob/362208e725db18baed550ade99087d44e7b537ed/packages/snaps-rpc-methods/src/endowments/name-lookup.ts#L151 +// To be removed once core has snaps-rpc-methods dependency +/** + * Getter function to get the chainIds caveat from a permission. + * + * This does basic validation of the caveat, but does not validate the type or + * value of the namespaces object itself, as this is handled by the + * `PermissionsController` when the permission is requested. + * + * @param permission - The permission to get the `chainIds` caveat from. + * @returns An array of `chainIds` that the snap supports. + */ +// istanbul ignore next +export function getChainIdsCaveat( + permission?: PermissionConstraint, +): string[] | null { + if (!permission?.caveats) { + return null; + } + + const caveat = permission.caveats.find( + (permCaveat) => permCaveat.type === SnapCaveatType.ChainIds, + ) as Caveat | undefined; + + return caveat ? caveat.value : null; +} diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts new file mode 100644 index 00000000000..8a5d063dd65 --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts @@ -0,0 +1,378 @@ +import { Messenger } from '@metamask/base-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { KeyringClient } from '@metamask/keyring-snap-client'; +import { useFakeTimers } from 'sinon'; + +import { MultiChainAssetsRatesController } from '.'; +import { + type AllowedActions, + type AllowedEvents, +} from './MultichainAssetsRatesController'; + +// A fake non‑EVM account (with Snap metadata) that meets the controller’s criteria. +const fakeNonEvmAccount: InternalAccount = { + id: 'account1', + type: 'solana:data-account', + address: '0x123', + metadata: { + name: 'Test Account', + // @ts-expect-error-next-line + snap: { id: 'test-snap', enabled: true }, + }, + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + options: {}, + methods: [], +}; + +// A fake EVM account (which should be filtered out). +const fakeEvmAccount: InternalAccount = { + id: 'account2', + type: 'eip155:eoa', + address: '0x456', + // @ts-expect-error-next-line + metadata: { name: 'EVM Account' }, + scopes: [], + options: {}, + methods: [], +}; + +const fakeEvmAccount2: InternalAccount = { + id: 'account3', + type: 'bip122:p2wpkh', + address: '0x789', + metadata: { + name: 'EVM Account', + // @ts-expect-error-next-line + snap: { id: 'test-snap', enabled: true }, + }, + scopes: [], + options: {}, + methods: [], +}; + +const fakeEvmAccountWithoutMetadata: InternalAccount = { + id: 'account4', + type: 'bip122:p2wpkh', + address: '0x789', + metadata: { + name: 'EVM Account', + importTime: 0, + keyring: { type: 'bip122' }, + }, + scopes: [], + options: {}, + methods: [], +}; + +// A fake conversion rates response returned by the SnapController. +const fakeAccountRates = { + conversionRates: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + 'swift:0/iso4217:USD': { + rate: '202.11', + conversionTime: 1738539923277, + }, + }, + }, +}; + +const setupController = ({ + config, + accountsAssets = [fakeNonEvmAccount, fakeEvmAccount, fakeEvmAccount2], +}: { + config?: Partial< + ConstructorParameters[0] + >; + accountsAssets?: InternalAccount[]; +} = {}) => { + const messenger = new Messenger(); + + messenger.registerActionHandler( + 'MultichainAssetsController:getState', + () => ({ + accountsAssets: { + account1: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + account2: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + account3: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + }, + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + name: 'Solana', + symbol: 'SOL', + fungible: true, + iconUrl: 'https://example.com/solana.png', + units: [{ symbol: 'SOL', name: 'Solana', decimals: 9 }], + }, + }, + }), + ); + + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => accountsAssets, + ); + + messenger.registerActionHandler('CurrencyRateController:getState', () => ({ + currencyRates: {}, + currentCurrency: 'USD', + })); + + const multiChainAssetsRatesControllerMessenger = messenger.getRestricted({ + name: 'MultiChainAssetsRatesController', + allowedActions: [ + 'AccountsController:listMultichainAccounts', + 'SnapController:handleRequest', + 'CurrencyRateController:getState', + 'MultichainAssetsController:getState', + ], + allowedEvents: [ + 'AccountsController:accountAdded', + 'KeyringController:lock', + 'KeyringController:unlock', + 'CurrencyRateController:stateChange', + 'MultichainAssetsController:stateChange', + ], + }); + + return { + controller: new MultiChainAssetsRatesController({ + messenger: multiChainAssetsRatesControllerMessenger, + ...config, + }), + messenger, + }; +}; + +describe('MultiChainAssetsRatesController', () => { + let clock: sinon.SinonFakeTimers; + + const mockedDate = 1705760550000; + + beforeEach(() => { + clock = useFakeTimers(); + jest.spyOn(Date, 'now').mockReturnValue(mockedDate); + }); + + afterEach(() => { + clock.restore(); + jest.restoreAllMocks(); + }); + + it('initializes with an empty conversionRates state', () => { + const { controller } = setupController(); + expect(controller.state).toStrictEqual({ conversionRates: {} }); + }); + + it('updates conversion rates for a valid non-EVM account', async () => { + const { controller, messenger } = setupController(); + + // Stub KeyringClient.listAccountAssets so that the controller “discovers” one asset. + jest + .spyOn(KeyringClient.prototype, 'listAccountAssets') + .mockResolvedValue([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]); + + // Override the SnapController:handleRequest handler to return our fake conversion rates. + const snapHandler = jest.fn().mockResolvedValue(fakeAccountRates); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + // Call updateAssetsRates for the valid non-EVM account. + await controller.updateAssetsRates(); + + // Check that the Snap request was made with the expected parameters. + expect(snapHandler).toHaveBeenCalledWith( + expect.objectContaining({ + handler: 'onAssetsConversion', + origin: 'metamask', + request: { + jsonrpc: '2.0', + method: 'onAssetsConversion', + params: { + conversions: [ + { + from: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + to: 'swift:0/iso4217:USD', + }, + ], + }, + }, + snapId: 'test-snap', + }), + ); + + // The controller state should now contain the conversion rates returned. + expect(controller.state.conversionRates).toStrictEqual( + // fakeAccountRates.conversionRates, + { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + rate: '202.11', + conversionTime: 1738539923277, + currency: 'swift:0/iso4217:USD', + }, + }, + ); + }); + + it('does not update conversion rates if the controller is not active', async () => { + const { controller, messenger } = setupController(); + + // Simulate a keyring lock event to set the controller as inactive. + messenger.publish('KeyringController:lock'); + // Override SnapController:handleRequest and stub listAccountAssets. + const snapHandler = jest.fn().mockResolvedValue(fakeAccountRates); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + jest + .spyOn(KeyringClient.prototype, 'listAccountAssets') + .mockResolvedValue([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]); + + await controller.updateAssetsRates(); + // Since the controller is locked, no update should occur. + expect(controller.state.conversionRates).toStrictEqual({}); + expect(snapHandler).not.toHaveBeenCalled(); + }); + + it('resumes update tokens rates when the keyring is unlocked', async () => { + const { controller, messenger } = setupController(); + messenger.publish('KeyringController:lock'); + // Override SnapController:handleRequest and stub listAccountAssets. + const snapHandler = jest.fn().mockResolvedValue(fakeAccountRates); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + jest + .spyOn(KeyringClient.prototype, 'listAccountAssets') + .mockResolvedValue([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]); + await controller.updateAssetsRates(); + expect(controller.isActive).toBe(false); + + messenger.publish('KeyringController:unlock'); + await controller.updateAssetsRates(); + + expect(controller.isActive).toBe(true); + }); + + it('calls updateTokensRates when _executePoll is invoked', async () => { + const { controller, messenger } = setupController(); + + jest + .spyOn(KeyringClient.prototype, 'listAccountAssets') + .mockResolvedValue([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]); + + messenger.registerActionHandler( + 'SnapController:handleRequest', + async () => ({ + conversionRates: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + 'swift:0/iso4217:USD': { + rate: '202.11', + conversionTime: 1738539923277, + }, + }, + }, + }), + ); + + // Spy on updateAssetsRates. + const updateSpy = jest.spyOn(controller, 'updateAssetsRates'); + await controller._executePoll(); + expect(updateSpy).toHaveBeenCalled(); + }); + + it('calls updateTokensRates when an multichain assets state is updated', async () => { + const { controller, messenger } = setupController(); + + // Spy on updateTokensRates. + const updateSpy = jest + .spyOn(controller, 'updateAssetsRates') + .mockResolvedValue(); + + // Publish a selectedAccountChange event. + // @ts-expect-error-next-line + messenger.publish('MultichainAssetsController:stateChange', { + accountsAssets: { + account3: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + }, + }); + // Wait for the asynchronous subscriber to run. + await Promise.resolve(); + expect(updateSpy).toHaveBeenCalled(); + }); + + it('handles partial or empty Snap responses gracefully', async () => { + const { controller, messenger } = setupController(); + + messenger.registerActionHandler('SnapController:handleRequest', () => { + return Promise.resolve({ + conversionRates: { + // Only returning a rate for one asset + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + 'swift:0/iso4217:USD': { + rate: '250.50', + conversionTime: 1738539923277, + }, + }, + }, + }); + }); + + await controller.updateAssetsRates(); + + expect(controller.state.conversionRates).toMatchObject({ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + rate: '250.50', + conversionTime: 1738539923277, + }, + }); + }); + + it('skips all accounts that lack Snap metadata or are EVM', async () => { + const { controller, messenger } = setupController({ + accountsAssets: [fakeEvmAccountWithoutMetadata], + }); + + const snapSpy = jest.fn().mockResolvedValue({ conversionRates: {} }); + messenger.registerActionHandler('SnapController:handleRequest', snapSpy); + + await controller.updateAssetsRates(); + + expect(snapSpy).not.toHaveBeenCalled(); + expect(controller.state.conversionRates).toStrictEqual({}); + }); + + it('updates state when currency is updated', async () => { + const { controller, messenger } = setupController(); + + const snapHandler = jest.fn().mockResolvedValue(fakeAccountRates); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + const updateSpy = jest.spyOn(controller, 'updateAssetsRates'); + + messenger.publish( + 'CurrencyRateController:stateChange', + { + currentCurrency: 'EUR', + currencyRates: {}, + }, + [], + ); + + expect(updateSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts new file mode 100644 index 00000000000..ebf7b85587f --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts @@ -0,0 +1,440 @@ +import type { + AccountsControllerListMultichainAccountsAction, + AccountsControllerAccountAddedEvent, +} from '@metamask/accounts-controller'; +import type { + RestrictedMessenger, + ControllerStateChangeEvent, + ControllerGetStateAction, +} from '@metamask/base-controller'; +import { type CaipAssetType, isEvmAccountType } from '@metamask/keyring-api'; +import type { + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { HandleSnapRequest } from '@metamask/snaps-controllers'; +import type { + SnapId, + AssetConversion, + OnAssetsConversionArguments, + OnAssetsConversionResponse, +} from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import { Mutex } from 'async-mutex'; +import type { Draft } from 'immer'; + +import { MAP_CAIP_CURRENCIES } from './constant'; +import type { + CurrencyRateState, + CurrencyRateStateChange, + GetCurrencyRateState, +} from '../CurrencyRateController'; +import type { + MultichainAssetsControllerGetStateAction, + MultichainAssetsControllerState, + MultichainAssetsControllerStateChangeEvent, +} from '../MultichainAssetsController'; + +/** + * The name of the MultiChainAssetsRatesController. + */ +const controllerName = 'MultiChainAssetsRatesController'; + +/** + * State used by the MultiChainAssetsRatesController to cache token conversion rates. + */ +export type MultichainAssetsRatesControllerState = { + conversionRates: Record; +}; + +/** + * Returns the state of the MultiChainAssetsRatesController. + */ +export type MultichainAssetsRatesControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + MultichainAssetsRatesControllerState + >; + +/** + * Action to update the rates of all supported tokens. + */ +export type MultichainAssetsRatesControllerUpdateRatesAction = { + type: `${typeof controllerName}:updateAssetsRates`; + handler: MultiChainAssetsRatesController['updateAssetsRates']; +}; + +/** + * Constructs the default {@link MultichainAssetsRatesController} 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 MultichainAssetsRatesController} state. + */ +export function getDefaultMultichainAssetsRatesControllerState(): MultichainAssetsRatesControllerState { + return { conversionRates: {} }; +} + +/** + * Event emitted when the state of the MultiChainAssetsRatesController changes. + */ +export type MultichainAssetsRatesControllerStateChange = + ControllerStateChangeEvent< + typeof controllerName, + MultichainAssetsRatesControllerState + >; + +/** + * Actions exposed by the MultiChainAssetsRatesController. + */ +export type MultichainAssetsRatesControllerActions = + | MultichainAssetsRatesControllerGetStateAction + | MultichainAssetsRatesControllerUpdateRatesAction; + +/** + * Events emitted by MultiChainAssetsRatesController. + */ +export type MultichainAssetsRatesControllerEvents = + MultichainAssetsRatesControllerStateChange; + +/** + * Actions that this controller is allowed to call. + */ +export type AllowedActions = + | HandleSnapRequest + | AccountsControllerListMultichainAccountsAction + | GetCurrencyRateState + | MultichainAssetsControllerGetStateAction; +/** + * Events that this controller is allowed to subscribe to. + */ +export type AllowedEvents = + | KeyringControllerLockEvent + | KeyringControllerUnlockEvent + | AccountsControllerAccountAddedEvent + | CurrencyRateStateChange + | MultichainAssetsControllerStateChangeEvent; + +/** + * Messenger type for the MultiChainAssetsRatesController. + */ +export type MultichainAssetsRatesControllerMessenger = RestrictedMessenger< + typeof controllerName, + MultichainAssetsRatesControllerActions | AllowedActions, + MultichainAssetsRatesControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + +/** + * The input for starting polling in MultiChainAssetsRatesController. + */ +export type MultiChainAssetsRatesPollingInput = { + accountId: string; +}; + +const metadata = { + conversionRates: { persist: true, anonymous: true }, +}; + +/** + * Controller that manages multichain token conversion rates. + * + * This controller polls for token conversion rates and updates its state. + */ +export class MultiChainAssetsRatesController extends StaticIntervalPollingController()< + typeof controllerName, + MultichainAssetsRatesControllerState, + MultichainAssetsRatesControllerMessenger +> { + readonly #mutex = new Mutex(); + + #currentCurrency: CurrencyRateState['currentCurrency']; + + #accountsAssets: MultichainAssetsControllerState['accountsAssets']; + + #isUnlocked = true; + + /** + * Creates an instance of MultiChainAssetsRatesController. + * + * @param options - Constructor options. + * @param options.interval - The polling interval in milliseconds. + * @param options.state - The initial state. + * @param options.messenger - A reference to the messaging system. + */ + constructor({ + interval = 18000, + state = {}, + messenger, + }: { + interval?: number; + state?: Partial; + messenger: MultichainAssetsRatesControllerMessenger; + }) { + super({ + name: controllerName, + messenger, + state: { + ...getDefaultMultichainAssetsRatesControllerState(), + ...state, + }, + metadata, + }); + + this.setIntervalLength(interval); + + // Subscribe to keyring lock/unlock events. + this.messagingSystem.subscribe('KeyringController:lock', () => { + this.#isUnlocked = false; + }); + this.messagingSystem.subscribe('KeyringController:unlock', () => { + this.#isUnlocked = true; + }); + + ({ accountsAssets: this.#accountsAssets } = this.messagingSystem.call( + 'MultichainAssetsController:getState', + )); + + ({ currentCurrency: this.#currentCurrency } = this.messagingSystem.call( + 'CurrencyRateController:getState', + )); + + this.messagingSystem.subscribe( + 'CurrencyRateController:stateChange', + async (currencyRatesState: CurrencyRateState) => { + this.#currentCurrency = currencyRatesState.currentCurrency; + await this.updateAssetsRates(); + }, + ); + + this.messagingSystem.subscribe( + 'MultichainAssetsController:stateChange', + async (multiChainAssetsState: MultichainAssetsControllerState) => { + this.#accountsAssets = multiChainAssetsState.accountsAssets; + await this.updateAssetsRates(); + }, + ); + } + + /** + * Executes a poll by updating token conversion rates for the current account. + * + * @returns A promise that resolves when the polling completes. + */ + async _executePoll(): Promise { + await this.updateAssetsRates(); + } + + /** + * Determines whether the controller is active. + * + * @returns True if the keyring is unlocked; otherwise, false. + */ + get isActive(): boolean { + return this.#isUnlocked; + } + + /** + * Checks if an account is a non-EVM account with a Snap. + * + * @param account - The account to check. + * @returns True if the account is non-EVM and has Snap metadata; otherwise, false. + */ + #isNonEvmAccount(account: InternalAccount): boolean { + return ( + !isEvmAccountType(account.type) && account.metadata.snap !== undefined + ); + } + + /** + * Retrieves all multichain accounts from the AccountsController. + * + * @returns An array of internal accounts. + */ + #listMultichainAccounts(): InternalAccount[] { + return this.messagingSystem.call( + 'AccountsController:listMultichainAccounts', + ); + } + + /** + * Filters and returns non-EVM accounts that should have balances. + * + * @returns An array of non-EVM internal accounts. + */ + #listAccounts(): InternalAccount[] { + const accounts = this.#listMultichainAccounts(); + return accounts.filter((account) => this.#isNonEvmAccount(account)); + } + + /** + * Updates token conversion rates for each non-EVM account. + * + * @returns A promise that resolves when the rates are updated. + */ + async updateAssetsRates(): Promise { + const releaseLock = await this.#mutex.acquire(); + + return (async () => { + if (!this.isActive) { + return; + } + const accounts = this.#listAccounts(); + + for (const account of accounts) { + const assets = this.#getAssetsForAccount(account.id); + + // Build the conversions array + const conversions = this.#buildConversions(assets); + + // Retrieve rates from Snap + const accountRates = await this.#handleSnapRequest({ + snapId: account?.metadata.snap?.id as SnapId, + handler: HandlerType.OnAssetsConversion, + params: conversions, + }); + + // Flatten nested rates if needed + const flattenedRates = this.#flattenRates(accountRates); + + // Build the updatedRates object for these assets + const updatedRates = this.#buildUpdatedRates(assets, flattenedRates); + // Apply these updated rates to controller state + this.#applyUpdatedRates(updatedRates); + } + })().finally(() => { + releaseLock(); + }); + } + + /** + * Returns the array of CAIP-19 assets for the given account ID. + * If none are found, returns an empty array. + * + * @param accountId - The account ID to get the assets for. + * @returns An array of CAIP-19 assets. + */ + #getAssetsForAccount(accountId: string): CaipAssetType[] { + return this.#accountsAssets?.[accountId] ?? []; + } + + /** + * Builds a conversions array (from each asset → the current currency). + * + * @param assets - The assets to build the conversions for. + * @returns A conversions array. + */ + #buildConversions(assets: CaipAssetType[]): OnAssetsConversionArguments { + const currency = + MAP_CAIP_CURRENCIES[this.#currentCurrency] ?? MAP_CAIP_CURRENCIES.usd; + return { + conversions: assets.map((asset) => ({ + from: asset, + to: currency, + })), + }; + } + + /** + * Flattens any nested structure in the conversion rates returned by Snap. + * + * @param assetsConversionResponse - The conversion rates to flatten. + * @returns A flattened rates object. + */ + #flattenRates( + assetsConversionResponse: OnAssetsConversionResponse, + ): Record { + const { conversionRates } = assetsConversionResponse; + + return Object.fromEntries( + Object.entries(conversionRates).map(([asset, nestedObj]) => { + // e.g., nestedObj might look like: { "swift:0/iso4217:EUR": { rate, conversionTime } } + const singleValue = Object.values(nestedObj)[0]; + return [asset, singleValue]; + }), + ); + } + + /** + * Builds a rates object that covers all given assets, ensuring that + * any asset not returned by Snap is set to null for both `rate` and `conversionTime`. + * + * @param assets - The assets to build the rates for. + * @param flattenedRates - The rates to merge. + * @returns A rates object that covers all given assets. + */ + #buildUpdatedRates( + assets: CaipAssetType[], + flattenedRates: Record, + ): Record { + const updatedRates: Record< + CaipAssetType, + AssetConversion & { currency: CaipAssetType } + > = {}; + + for (const asset of assets) { + if (flattenedRates[asset]) { + updatedRates[asset] = { + ...(flattenedRates[asset] as AssetConversion), + currency: + MAP_CAIP_CURRENCIES[this.#currentCurrency] ?? + MAP_CAIP_CURRENCIES.usd, + }; + } + } + return updatedRates; + } + + /** + * Merges the new rates into the controller’s state. + * + * @param updatedRates - The new rates to merge. + */ + #applyUpdatedRates( + updatedRates: Record< + string, + { rate: string | null; conversionTime: number | null } + >, + ): void { + this.update((state: Draft) => { + state.conversionRates = { + ...state.conversionRates, + ...updatedRates, + }; + }); + } + + /** + * Forwards a Snap request to the SnapController. + * + * @param args - The request parameters. + * @param args.snapId - The ID of the Snap. + * @param args.handler - The handler type. + * @param args.params - The asset conversions. + * @returns A promise that resolves with the account rates. + */ + async #handleSnapRequest({ + snapId, + handler, + params, + }: { + snapId: SnapId; + handler: HandlerType; + params: OnAssetsConversionArguments; + }): Promise { + return this.messagingSystem.call('SnapController:handleRequest', { + snapId, + origin: 'metamask', + handler, + request: { + jsonrpc: '2.0', + method: handler, + params, + }, + }) as Promise; + } +} diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts new file mode 100644 index 00000000000..2fef0e8155d --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts @@ -0,0 +1,92 @@ +import type { CaipAssetType } from '@metamask/utils'; + +/** + * Maps each SUPPORTED_CURRENCIES entry to its CAIP-19 (or CAIP-like) identifier. + * For fiat, we mimic the old “swift:0/iso4217:XYZ” style. + */ +export const MAP_CAIP_CURRENCIES: { + [key: string]: CaipAssetType; +} = { + // ======================== + // Native crypto assets + // ======================== + btc: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + eth: 'eip155:1/slip44:60', + ltc: 'bip122:12a765e31ffd4059bada1e25190f6e98/slip44:2', + + // Bitcoin Cash + bch: 'bip122:000000000000000000651ef99cb9fcbe/slip44:145', + + // Binance Coin + bnb: 'cosmos:Binance-Chain-Tigris/slip44:714', + + // EOS mainnet (chainId = aca376f2...) + eos: 'eos:aca376f2/slip44:194', + + // XRP mainnet + xrp: 'xrpl:mainnet/slip44:144', + + // Stellar Lumens mainnet + xlm: 'stellar:pubnet/slip44:148', + + // Chainlink (ERC20 on Ethereum mainnet) + link: 'eip155:1/erc20:0x514910771af9Ca656af840dff83E8264EcF986CA', + + // Polkadot (chainId = 91b171bb158e2d3848fa23a9f1c25182) + dot: 'polkadot:91b171bb158e2d3848fa23a9f1c25182/slip44:354', + + // Yearn.finance (ERC20 on Ethereum mainnet) + yfi: 'eip155:1/erc20:0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', + + // ======================== + // Fiat currencies + // ======================== + usd: 'swift:0/iso4217:USD', + aed: 'swift:0/iso4217:AED', + ars: 'swift:0/iso4217:ARS', + aud: 'swift:0/iso4217:AUD', + bdt: 'swift:0/iso4217:BDT', + bhd: 'swift:0/iso4217:BHD', + bmd: 'swift:0/iso4217:BMD', + brl: 'swift:0/iso4217:BRL', + cad: 'swift:0/iso4217:CAD', + chf: 'swift:0/iso4217:CHF', + clp: 'swift:0/iso4217:CLP', + cny: 'swift:0/iso4217:CNY', + czk: 'swift:0/iso4217:CZK', + dkk: 'swift:0/iso4217:DKK', + eur: 'swift:0/iso4217:EUR', + gbp: 'swift:0/iso4217:GBP', + hkd: 'swift:0/iso4217:HKD', + huf: 'swift:0/iso4217:HUF', + idr: 'swift:0/iso4217:IDR', + ils: 'swift:0/iso4217:ILS', + inr: 'swift:0/iso4217:INR', + jpy: 'swift:0/iso4217:JPY', + krw: 'swift:0/iso4217:KRW', + kwd: 'swift:0/iso4217:KWD', + lkr: 'swift:0/iso4217:LKR', + mmk: 'swift:0/iso4217:MMK', + mxn: 'swift:0/iso4217:MXN', + myr: 'swift:0/iso4217:MYR', + ngn: 'swift:0/iso4217:NGN', + nok: 'swift:0/iso4217:NOK', + nzd: 'swift:0/iso4217:NZD', + php: 'swift:0/iso4217:PHP', + pkr: 'swift:0/iso4217:PKR', + pln: 'swift:0/iso4217:PLN', + rub: 'swift:0/iso4217:RUB', + sar: 'swift:0/iso4217:SAR', + sek: 'swift:0/iso4217:SEK', + sgd: 'swift:0/iso4217:SGD', + thb: 'swift:0/iso4217:THB', + try: 'swift:0/iso4217:TRY', + twd: 'swift:0/iso4217:TWD', + uah: 'swift:0/iso4217:UAH', + vef: 'swift:0/iso4217:VEF', + vnd: 'swift:0/iso4217:VND', + zar: 'swift:0/iso4217:ZAR', + xdr: 'swift:0/iso4217:XDR', + xag: 'swift:0/iso4217:XAG', + xau: 'swift:0/iso4217:XAU', +}; diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/index.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/index.ts new file mode 100644 index 00000000000..c145b3d21c8 --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/index.ts @@ -0,0 +1,13 @@ +export type { + MultichainAssetsRatesControllerState, + MultichainAssetsRatesControllerActions, + MultichainAssetsRatesControllerEvents, + MultichainAssetsRatesControllerGetStateAction, + MultichainAssetsRatesControllerStateChange, + MultichainAssetsRatesControllerMessenger, +} from './MultichainAssetsRatesController'; + +export { + MultiChainAssetsRatesController, + getDefaultMultichainAssetsRatesControllerState, +} from './MultichainAssetsRatesController'; diff --git a/packages/assets-controllers/src/MultichainBalancesController/BalancesTracker.test.ts b/packages/assets-controllers/src/MultichainBalancesController/BalancesTracker.test.ts deleted file mode 100644 index ed6409199f1..00000000000 --- a/packages/assets-controllers/src/MultichainBalancesController/BalancesTracker.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { BtcAccountType, BtcMethod } from '@metamask/keyring-api'; -import { KeyringTypes } from '@metamask/keyring-controller'; -import { v4 as uuidv4 } from 'uuid'; - -import { BalancesTracker } from './BalancesTracker'; -import { Poller } from './Poller'; - -const MOCK_TIMESTAMP = 1709983353; - -const mockBtcAccount = { - address: '', - id: uuidv4(), - metadata: { - name: 'Bitcoin Account 1', - importTime: Date.now(), - keyring: { - type: KeyringTypes.snap, - }, - snap: { - id: 'mock-btc-snap', - name: 'mock-btc-snap', - enabled: true, - }, - lastSelected: 0, - }, - options: {}, - methods: [BtcMethod.SendBitcoin], - type: BtcAccountType.P2wpkh, -}; - -/** - * Sets up a BalancesTracker instance for testing. - * @returns The BalancesTracker instance and a mock update balance function. - */ -function setupTracker() { - const mockUpdateBalance = jest.fn(); - const tracker = new BalancesTracker(mockUpdateBalance); - - return { - tracker, - mockUpdateBalance, - }; -} - -describe('BalancesTracker', () => { - it('starts polling when calling start', async () => { - const { tracker } = setupTracker(); - const spyPoller = jest.spyOn(Poller.prototype, 'start'); - - tracker.start(); - expect(spyPoller).toHaveBeenCalledTimes(1); - }); - - it('stops polling when calling stop', async () => { - const { tracker } = setupTracker(); - const spyPoller = jest.spyOn(Poller.prototype, 'stop'); - - tracker.start(); - tracker.stop(); - expect(spyPoller).toHaveBeenCalledTimes(1); - }); - - it('is not tracking if none accounts have been registered', async () => { - const { tracker, mockUpdateBalance } = setupTracker(); - - tracker.start(); - await tracker.updateBalances(); - - expect(mockUpdateBalance).not.toHaveBeenCalled(); - }); - - it('tracks account balances', async () => { - const { tracker, mockUpdateBalance } = setupTracker(); - - tracker.start(); - // We must track account IDs explicitly - tracker.track(mockBtcAccount.id, 0); - // Trigger balances refresh (not waiting for the Poller here) - await tracker.updateBalances(); - - expect(mockUpdateBalance).toHaveBeenCalledWith(mockBtcAccount.id); - }); - - it('untracks account balances', async () => { - const { tracker, mockUpdateBalance } = setupTracker(); - - tracker.start(); - tracker.track(mockBtcAccount.id, 0); - await tracker.updateBalances(); - expect(mockUpdateBalance).toHaveBeenCalledWith(mockBtcAccount.id); - - tracker.untrack(mockBtcAccount.id); - await tracker.updateBalances(); - expect(mockUpdateBalance).toHaveBeenCalledTimes(1); // No second call after untracking - }); - - it('tracks account after being registered', async () => { - const { tracker } = setupTracker(); - - tracker.start(); - tracker.track(mockBtcAccount.id, 0); - expect(tracker.isTracked(mockBtcAccount.id)).toBe(true); - }); - - it('does not track account if not registered', async () => { - const { tracker } = setupTracker(); - - tracker.start(); - expect(tracker.isTracked(mockBtcAccount.id)).toBe(false); - }); - - it('does not refresh balance if they are considered up-to-date', async () => { - const { tracker, mockUpdateBalance } = setupTracker(); - - const blockTime = 10 * 60 * 1000; // 10 minutes in milliseconds. - jest - .spyOn(global.Date, 'now') - .mockImplementation(() => new Date(MOCK_TIMESTAMP).getTime()); - - tracker.start(); - tracker.track(mockBtcAccount.id, blockTime); - await tracker.updateBalances(); - expect(mockUpdateBalance).toHaveBeenCalledTimes(1); - - await tracker.updateBalances(); - expect(mockUpdateBalance).toHaveBeenCalledTimes(1); // No second call since the balances is already still up-to-date - - jest - .spyOn(global.Date, 'now') - .mockImplementation(() => new Date(MOCK_TIMESTAMP + blockTime).getTime()); - - await tracker.updateBalances(); - expect(mockUpdateBalance).toHaveBeenCalledTimes(2); // Now the balance will update - }); - - it('throws an error if trying to update balance of an untracked account', async () => { - const { tracker } = setupTracker(); - - await expect(tracker.updateBalance(mockBtcAccount.id)).rejects.toThrow( - `Account is not being tracked: ${mockBtcAccount.id}`, - ); - }); -}); diff --git a/packages/assets-controllers/src/MultichainBalancesController/BalancesTracker.ts b/packages/assets-controllers/src/MultichainBalancesController/BalancesTracker.ts deleted file mode 100644 index 661c229a82d..00000000000 --- a/packages/assets-controllers/src/MultichainBalancesController/BalancesTracker.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { Poller } from './Poller'; - -type BalanceInfo = { - lastUpdated: number; - blockTime: number; -}; - -const BALANCES_TRACKING_INTERVAL = 5000; // Every 5s in milliseconds. - -export class BalancesTracker { - #poller: Poller; - - #updateBalance: (accountId: string) => Promise; - - #balances: Record = {}; - - constructor(updateBalanceCallback: (accountId: string) => Promise) { - this.#updateBalance = updateBalanceCallback; - - this.#poller = new Poller( - () => this.updateBalances(), - BALANCES_TRACKING_INTERVAL, - ); - } - - /** - * Starts the tracking process. - */ - start(): void { - this.#poller.start(); - } - - /** - * Stops the tracking process. - */ - stop(): void { - this.#poller.stop(); - } - - /** - * Checks if an account ID is being tracked. - * - * @param accountId - The account ID. - * @returns True if the account is being tracked, false otherwise. - */ - isTracked(accountId: string) { - return Object.prototype.hasOwnProperty.call(this.#balances, accountId); - } - - /** - * Asserts that an account ID is being tracked. - * - * @param accountId - The account ID. - * @throws If the account ID is not being tracked. - */ - assertBeingTracked(accountId: string) { - if (!this.isTracked(accountId)) { - throw new Error(`Account is not being tracked: ${accountId}`); - } - } - - /** - * Starts tracking a new account ID. This method has no effect on already tracked - * accounts. - * - * @param accountId - The account ID. - * @param blockTime - The block time (used when refreshing the account balances). - */ - track(accountId: string, blockTime: number) { - // Do not overwrite current info if already being tracked! - if (!this.isTracked(accountId)) { - this.#balances[accountId] = { - lastUpdated: 0, - blockTime, - }; - } - } - - /** - * Stops tracking a tracked account ID. - * - * @param accountId - The account ID. - * @throws If the account ID is not being tracked. - */ - untrack(accountId: string) { - this.assertBeingTracked(accountId); - delete this.#balances[accountId]; - } - - /** - * Update the balances for a tracked account ID. - * - * @param accountId - The account ID. - * @throws If the account ID is not being tracked. - */ - async updateBalance(accountId: string) { - this.assertBeingTracked(accountId); - - // We check if the balance is outdated (by comparing to the block time associated - // with this kind of account). - // - // This might not be super accurate, but we could probably compute this differently - // and try to sync with the "real block time"! - const info = this.#balances[accountId]; - if (this.#isBalanceOutdated(info)) { - await this.#updateBalance(accountId); - this.#balances[accountId].lastUpdated = Date.now(); - } - } - - /** - * Update the balances of all tracked accounts (only if the balances - * is considered outdated). - */ - async updateBalances() { - await Promise.allSettled( - Object.keys(this.#balances).map(async (accountId) => { - await this.updateBalance(accountId); - }), - ); - } - - /** - * Checks if the balance is outdated according to the provided data. - * - * @param param - The balance info. - * @param param.lastUpdated - The last updated timestamp. - * @param param.blockTime - The block time. - * @returns True if the balance is outdated, false otherwise. - */ - #isBalanceOutdated({ lastUpdated, blockTime }: BalanceInfo): boolean { - return ( - // Never been updated: - lastUpdated === 0 || - // Outdated: - Date.now() - lastUpdated >= blockTime - ); - } -} diff --git a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts index 87f200ab550..9cdd6a4fc43 100644 --- a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts +++ b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts @@ -1,31 +1,30 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import type { Balance, CaipAssetType } from '@metamask/keyring-api'; import { BtcAccountType, BtcMethod, EthAccountType, EthMethod, - BtcScopes, - EthScopes, - SolScopes, + BtcScope, + EthScope, + SolScope, + SolMethod, + SolAccountType, } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { v4 as uuidv4 } from 'uuid'; +import { MultichainBalancesController } from '.'; +import type { + MultichainBalancesControllerMessenger, + MultichainBalancesControllerState, +} from '.'; +import { getDefaultMultichainBalancesControllerState } from './MultichainBalancesController'; import type { ExtractAvailableAction, ExtractAvailableEvent, } from '../../../base-controller/tests/helpers'; -import { BalancesTracker } from './BalancesTracker'; -import { - MultichainBalancesController, - getDefaultMultichainBalancesControllerState, -} from './MultichainBalancesController'; -import type { - MultichainBalancesControllerMessenger, - MultichainBalancesControllerState, -} from './MultichainBalancesController'; const mockBtcAccount = { address: 'bc1qssdcp5kvwh6nghzg9tuk99xsflwkdv4hgvq58q', @@ -43,12 +42,34 @@ const mockBtcAccount = { }, lastSelected: 0, }, - scopes: [BtcScopes.Namespace], + scopes: [BtcScope.Testnet], options: {}, methods: [BtcMethod.SendBitcoin], type: BtcAccountType.P2wpkh, }; +const mockSolAccount = { + address: 'EBBYfhQzVzurZiweJ2keeBWpgGLs1cbWYcz28gjGgi5x', + id: uuidv4(), + metadata: { + name: 'Solana Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-sol-snap', + name: 'mock-sol-snap', + enabled: true, + }, + lastSelected: 0, + }, + scopes: [SolScope.Devnet], + options: {}, + methods: [SolMethod.SendAndConfirmTransaction], + type: SolAccountType.DataAccount, +}; + const mockEthAccount = { address: '0x807dE1cf8f39E83258904b2f7b473E5C506E4aC1', id: uuidv4(), @@ -65,15 +86,16 @@ const mockEthAccount = { }, lastSelected: 0, }, - scopes: [EthScopes.Namespace], + scopes: [EthScope.Eoa], options: {}, methods: [EthMethod.SignTypedDataV4, EthMethod.SignTransaction], type: EthAccountType.Eoa, }; +const mockBtcNativeAsset = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0'; const mockBalanceResult = { - 'bip122:000000000933ea01ad0ee984209779ba/slip44:0': { - amount: '0.00000000', + [mockBtcNativeAsset]: { + amount: '1.00000000', unit: 'BTC', }, }; @@ -94,11 +116,33 @@ type RootEvent = ExtractAvailableEvent; * * @returns The unrestricted messenger suited for PetNamesController. */ -function getRootControllerMessenger(): ControllerMessenger< - RootAction, - RootEvent -> { - return new ControllerMessenger(); +function getRootMessenger(): Messenger { + return new Messenger(); +} + +/** + * Constructs the restricted messenger for the MultichainBalancesController. + * + * @param messenger - The root messenger. + * @returns The unrestricted messenger suited for MultichainBalancesController. + */ +function getRestrictedMessenger( + messenger: Messenger, +): MultichainBalancesControllerMessenger { + return messenger.getRestricted({ + name: 'MultichainBalancesController', + allowedActions: [ + 'SnapController:handleRequest', + 'AccountsController:listMultichainAccounts', + 'MultichainAssetsController:getState', + ], + allowedEvents: [ + 'AccountsController:accountAdded', + 'AccountsController:accountRemoved', + 'AccountsController:accountBalancesUpdated', + 'MultichainAssetsController:stateChange', + ], + }); } const setupController = ({ @@ -111,23 +155,11 @@ const setupController = ({ handleRequestReturnValue?: Record; }; } = {}) => { - const controllerMessenger = getRootControllerMessenger(); - - const multichainBalancesControllerMessenger: MultichainBalancesControllerMessenger = - controllerMessenger.getRestricted({ - name: 'MultichainBalancesController', - allowedActions: [ - 'SnapController:handleRequest', - 'AccountsController:listMultichainAccounts', - ], - allowedEvents: [ - 'AccountsController:accountAdded', - 'AccountsController:accountRemoved', - ], - }); + const messenger = getRootMessenger(); + const multichainBalancesMessenger = getRestrictedMessenger(messenger); const mockSnapHandleRequest = jest.fn(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'SnapController:handleRequest', mockSnapHandleRequest.mockReturnValue( mocks?.handleRequestReturnValue ?? mockBalanceResult, @@ -135,127 +167,263 @@ const setupController = ({ ); const mockListMultichainAccounts = jest.fn(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'AccountsController:listMultichainAccounts', mockListMultichainAccounts.mockReturnValue( mocks?.listMultichainAccounts ?? [mockBtcAccount, mockEthAccount], ), ); + const mockGetAssetsState = jest.fn().mockReturnValue({ + accountsAssets: { + [mockBtcAccount.id]: [mockBtcNativeAsset], + }, + }); + messenger.registerActionHandler( + 'MultichainAssetsController:getState', + mockGetAssetsState, + ); + const controller = new MultichainBalancesController({ - messenger: multichainBalancesControllerMessenger, + messenger: multichainBalancesMessenger, state, }); return { controller, - messenger: controllerMessenger, + messenger, mockSnapHandleRequest, mockListMultichainAccounts, + mockGetAssetsState, }; }; +/** + * Utility function that waits for all pending promises to be resolved. + * This is necessary when testing asynchronous execution flows that are + * initiated by synchronous calls. + * + * @returns A promise that resolves when all pending promises are completed. + */ +async function waitForAllPromises(): Promise { + // Wait for next tick to flush all pending promises. It's requires since + // we are testing some asynchronous execution flows that are started by + // synchronous calls. + await new Promise(process.nextTick); +} + describe('BalancesController', () => { it('initialize with default state', () => { - const { controller } = setupController({}); + const messenger = getRootMessenger(); + const multichainBalancesMessenger = getRestrictedMessenger(messenger); + + messenger.registerActionHandler('SnapController:handleRequest', jest.fn()); + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + jest.fn().mockReturnValue([]), + ); + messenger.registerActionHandler( + 'MultichainAssetsController:getState', + jest.fn(), + ); + + const controller = new MultichainBalancesController({ + messenger: multichainBalancesMessenger, + }); expect(controller.state).toStrictEqual({ balances: {} }); }); - it('starts tracking when calling start', async () => { - const spyTracker = jest.spyOn(BalancesTracker.prototype, 'start'); + it('updates the balance for a specific account', async () => { const { controller } = setupController(); - controller.start(); - expect(spyTracker).toHaveBeenCalledTimes(1); - }); + await controller.updateBalance(mockBtcAccount.id); - it('stops tracking when calling stop', async () => { - const spyTracker = jest.spyOn(BalancesTracker.prototype, 'stop'); - const { controller } = setupController(); - controller.start(); - controller.stop(); - expect(spyTracker).toHaveBeenCalledTimes(1); + expect(controller.state.balances[mockBtcAccount.id]).toStrictEqual( + mockBalanceResult, + ); }); - it('updates balances when calling updateBalances', async () => { - const { controller } = setupController(); - - await controller.updateBalances(); + it('updates balances when "AccountsController:accountRemoved" is fired', async () => { + const { controller, messenger } = setupController(); + await controller.updateBalance(mockBtcAccount.id); expect(controller.state).toStrictEqual({ balances: { [mockBtcAccount.id]: mockBalanceResult, }, }); + + messenger.publish('AccountsController:accountRemoved', mockBtcAccount.id); + + expect(controller.state).toStrictEqual({ + balances: {}, + }); }); - it('updates the balance for a specific account when calling updateBalance', async () => { - const { controller } = setupController(); + it('does not track balances for EVM accounts', async () => { + const { controller, messenger, mockListMultichainAccounts } = + setupController({ + mocks: { + listMultichainAccounts: [], + }, + }); - await controller.updateBalance(mockBtcAccount.id); + mockListMultichainAccounts.mockReturnValue([mockEthAccount]); + messenger.publish('AccountsController:accountAdded', mockEthAccount); expect(controller.state).toStrictEqual({ - balances: { - [mockBtcAccount.id]: mockBalanceResult, - }, + balances: {}, }); }); - it('updates balances when "AccountsController:accountAdded" is fired', async () => { - const { controller, messenger, mockListMultichainAccounts } = + it('handles errors gracefully when updating balance', async () => { + const { controller, mockSnapHandleRequest, mockListMultichainAccounts } = setupController({ mocks: { listMultichainAccounts: [], }, }); - controller.start(); + mockSnapHandleRequest.mockReset(); + mockSnapHandleRequest.mockImplementation(() => + Promise.reject(new Error('Failed to fetch')), + ); mockListMultichainAccounts.mockReturnValue([mockBtcAccount]); - messenger.publish('AccountsController:accountAdded', mockBtcAccount); - await controller.updateBalances(); - expect(controller.state).toStrictEqual({ - balances: { - [mockBtcAccount.id]: mockBalanceResult, + await controller.updateBalance(mockBtcAccount.id); + await waitForAllPromises(); + + expect(controller.state.balances).toStrictEqual({}); + }); + + it('handles errors gracefully when account could not be found', async () => { + const { controller } = setupController({ + mocks: { + listMultichainAccounts: [], }, }); + + await controller.updateBalance(mockBtcAccount.id); + await waitForAllPromises(); + + expect(controller.state.balances).toStrictEqual({}); }); - it('updates balances when "AccountsController:accountRemoved" is fired', async () => { - const { controller, messenger, mockListMultichainAccounts } = - setupController(); + it('handles errors when trying to upgrade the balance of a non-existing account', async () => { + const { controller } = setupController({ + mocks: { + listMultichainAccounts: [mockBtcAccount], + }, + }); - controller.start(); - await controller.updateBalances(); - expect(controller.state).toStrictEqual({ + // Solana account is not registered, so this should not update anything for this account + await controller.updateBalance(mockSolAccount.id); + expect(controller.state.balances).toStrictEqual({}); + }); + + it('stores balances when receiving new balances from the "AccountsController:accountBalancesUpdated" event', async () => { + const { controller, messenger } = setupController(); + const balanceUpdate = { balances: { [mockBtcAccount.id]: mockBalanceResult, }, + }; + + messenger.publish( + 'AccountsController:accountBalancesUpdated', + balanceUpdate, + ); + + await waitForAllPromises(); + + expect(controller.state.balances[mockBtcAccount.id]).toStrictEqual( + mockBalanceResult, + ); + }); + + it('updates balances when receiving "AccountsController:accountBalancesUpdated" event', async () => { + const mockInitialBalances = { + [mockBtcNativeAsset]: { + amount: '0.00000000', + unit: 'BTC', + }, + }; + // Just to make sure we will run a "true update", we want to make the + // initial state is different from the updated one. + expect(mockInitialBalances).not.toStrictEqual(mockBalanceResult); + + const { controller, messenger } = setupController({ + state: { + balances: { + [mockBtcAccount.id]: mockInitialBalances, + }, + }, }); + const balanceUpdate = { + balances: { + [mockBtcAccount.id]: mockBalanceResult, + }, + }; - messenger.publish('AccountsController:accountRemoved', mockBtcAccount.id); - mockListMultichainAccounts.mockReturnValue([]); - await controller.updateBalances(); + messenger.publish( + 'AccountsController:accountBalancesUpdated', + balanceUpdate, + ); - expect(controller.state).toStrictEqual({ - balances: {}, + await waitForAllPromises(); + + expect(controller.state.balances[mockBtcAccount.id]).toStrictEqual( + mockBalanceResult, + ); + }); + + it('fetches initial balances for existing non-EVM accounts', async () => { + const { controller } = setupController({ + mocks: { + listMultichainAccounts: [mockBtcAccount], + }, }); + + await waitForAllPromises(); + + expect(controller.state.balances[mockBtcAccount.id]).toStrictEqual( + mockBalanceResult, + ); }); - it('does not track balances for EVM accounts', async () => { - const { controller, messenger, mockListMultichainAccounts } = - setupController({ - mocks: { - listMultichainAccounts: [], + it('handles an account with no assets in MultichainAssetsController state', async () => { + const { controller, mockGetAssetsState } = setupController({ + mocks: { + handleRequestReturnValue: {}, + }, + }); + + mockGetAssetsState.mockReturnValue({ + accountsAssets: {}, + }); + + await controller.updateBalance(mockBtcAccount.id); + + expect(controller.state.balances[mockBtcAccount.id]).toStrictEqual({}); + }); + + it('updates balances when receiving "MultichainAssetsController:stateChange" event', async () => { + const { controller, messenger } = setupController(); + + messenger.publish( + 'MultichainAssetsController:stateChange', + { + assetsMetadata: {}, + accountsAssets: { + [mockBtcAccount.id]: [mockBtcNativeAsset], }, - }); + }, + [], + ); - controller.start(); - mockListMultichainAccounts.mockReturnValue([mockEthAccount]); - messenger.publish('AccountsController:accountAdded', mockEthAccount); - await controller.updateBalances(); + await waitForAllPromises(); - expect(controller.state).toStrictEqual({ - balances: {}, - }); + expect(controller.state.balances[mockBtcAccount.id]).toStrictEqual( + mockBalanceResult, + ); }); }); diff --git a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts index 9442607e564..97b45e4fe9b 100644 --- a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts +++ b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts @@ -2,15 +2,20 @@ import type { AccountsControllerAccountAddedEvent, AccountsControllerAccountRemovedEvent, AccountsControllerListMultichainAccountsAction, + AccountsControllerAccountBalancesUpdatesEvent, } from '@metamask/accounts-controller'; import { BaseController, type ControllerGetStateAction, type ControllerStateChangeEvent, - type RestrictedControllerMessenger, + type RestrictedMessenger, } from '@metamask/base-controller'; import { isEvmAccountType } from '@metamask/keyring-api'; -import type { Balance, CaipAssetType } from '@metamask/keyring-api'; +import type { + Balance, + CaipAssetType, + AccountBalancesUpdatedEventPayload, +} from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { KeyringClient } from '@metamask/keyring-snap-client'; import type { HandleSnapRequest } from '@metamask/snaps-controllers'; @@ -19,8 +24,11 @@ import { HandlerType } from '@metamask/snaps-utils'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import type { Draft } from 'immer'; -import { BalancesTracker, NETWORK_ASSETS_MAP } from '.'; -import { getScopeForAccount, getBlockTimeForAccount } from './utils'; +import type { + MultichainAssetsControllerGetStateAction, + MultichainAssetsControllerState, + MultichainAssetsControllerStateChangeEvent, +} from '../MultichainAssetsController'; const controllerName = 'MultichainBalancesController'; @@ -59,14 +67,6 @@ export type MultichainBalancesControllerGetStateAction = MultichainBalancesControllerState >; -/** - * Updates the balances of all supported accounts. - */ -export type MultichainBalancesControllerUpdateBalancesAction = { - type: `${typeof controllerName}:updateBalances`; - handler: MultichainBalancesController['updateBalances']; -}; - /** * Event emitted when the state of the {@link MultichainBalancesController} changes. */ @@ -80,8 +80,7 @@ export type MultichainBalancesControllerStateChange = * Actions exposed by the {@link MultichainBalancesController}. */ export type MultichainBalancesControllerActions = - | MultichainBalancesControllerGetStateAction - | MultichainBalancesControllerUpdateBalancesAction; + MultichainBalancesControllerGetStateAction; /** * Events emitted by {@link MultichainBalancesController}. @@ -94,26 +93,28 @@ export type MultichainBalancesControllerEvents = */ type AllowedActions = | HandleSnapRequest - | AccountsControllerListMultichainAccountsAction; + | AccountsControllerListMultichainAccountsAction + | MultichainAssetsControllerGetStateAction; /** * Events that this controller is allowed to subscribe. */ type AllowedEvents = | AccountsControllerAccountAddedEvent - | AccountsControllerAccountRemovedEvent; + | AccountsControllerAccountRemovedEvent + | AccountsControllerAccountBalancesUpdatesEvent + | MultichainAssetsControllerStateChangeEvent; /** * Messenger type for the MultichainBalancesController. */ -export type MultichainBalancesControllerMessenger = - RestrictedControllerMessenger< - typeof controllerName, - MultichainBalancesControllerActions | AllowedActions, - MultichainBalancesControllerEvents | AllowedEvents, - AllowedActions['type'], - AllowedEvents['type'] - >; +export type MultichainBalancesControllerMessenger = RestrictedMessenger< + typeof controllerName, + MultichainBalancesControllerActions | AllowedActions, + MultichainBalancesControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; /** * {@link MultichainBalancesController}'s metadata. @@ -138,8 +139,6 @@ export class MultichainBalancesController extends BaseController< MultichainBalancesControllerState, MultichainBalancesControllerMessenger > { - #tracker: BalancesTracker; - constructor({ messenger, state = {}, @@ -157,39 +156,36 @@ export class MultichainBalancesController extends BaseController< }, }); - this.#tracker = new BalancesTracker( - async (accountId: string) => await this.#updateBalance(accountId), - ); - - // Register all non-EVM accounts into the tracker + // Fetch initial balances for all non-EVM accounts for (const account of this.#listAccounts()) { - if (this.#isNonEvmAccount(account)) { - this.#tracker.track(account.id, getBlockTimeForAccount(account.type)); - } + // Fetching the balance is asynchronous and we cannot use `await` here. + // eslint-disable-next-line no-void + void this.updateBalance(account.id); } this.messagingSystem.subscribe( - 'AccountsController:accountAdded', - (account) => this.#handleOnAccountAdded(account), + 'AccountsController:accountRemoved', + (account: string) => this.#handleOnAccountRemoved(account), ); this.messagingSystem.subscribe( - 'AccountsController:accountRemoved', - (account) => this.#handleOnAccountRemoved(account), + 'AccountsController:accountBalancesUpdated', + (balanceUpdate: AccountBalancesUpdatedEventPayload) => + this.#handleOnAccountBalancesUpdated(balanceUpdate), + ); + // TODO: Maybe add a MultichainAssetsController:accountAssetListUpdated event instead of using the entire state. + // Since MultichainAssetsController already listens for the AccountsController:accountAdded, we can rely in it for that event + // and not listen for it also here, in this controller, since it would be redundant + this.messagingSystem.subscribe( + 'MultichainAssetsController:stateChange', + async (assetsState: MultichainAssetsControllerState) => { + for (const accountId of Object.keys(assetsState.accountsAssets)) { + await this.#updateBalance( + accountId, + assetsState.accountsAssets[accountId], + ); + } + }, ); - } - - /** - * Starts the polling process. - */ - start(): void { - this.#tracker.start(); - } - - /** - * Stops the polling process. - */ - stop(): void { - this.#tracker.stop(); } /** @@ -197,19 +193,45 @@ export class MultichainBalancesController extends BaseController< * anything, but it updates the state of the controller. * * @param accountId - The account ID. + * @param assets - The list of asset types for this account to upadte. */ - async updateBalance(accountId: string): Promise { - // NOTE: No need to track the account here, since we start tracking those when - // the "AccountsController:accountAdded" is fired. - await this.#tracker.updateBalance(accountId); + async #updateBalance( + accountId: string, + assets: CaipAssetType[], + ): Promise { + try { + const account = this.#getAccount(accountId); + + if (account.metadata.snap) { + const accountBalance = await this.#getBalances( + account.id, + account.metadata.snap.id, + assets, + ); + + this.update((state: Draft) => { + state.balances[accountId] = accountBalance; + }); + } + } catch (error) { + // FIXME: Maybe we shouldn't catch all errors here since this method is also being + // used in the public methods. This means if something else uses `updateBalance` it + // won't be able to catch and gets the error itself... + console.error( + `Failed to fetch balances for account ${accountId}:`, + error, + ); + } } /** - * Updates the balances of all supported accounts. This method doesn't return + * Updates the balances of one account. This method doesn't return * anything, but it updates the state of the controller. + * + * @param accountId - The account ID. */ - async updateBalances(): Promise { - await this.#tracker.updateBalances(); + async updateBalance(accountId: string): Promise { + await this.#updateBalance(accountId, this.#listAccountAssets(accountId)); } /** @@ -234,6 +256,21 @@ export class MultichainBalancesController extends BaseController< return accounts.filter((account) => this.#isNonEvmAccount(account)); } + /** + * Lists the accounts assets. + * + * @param accountId - The account ID. + * @returns The list of assets for this account, returns an empty list if none. + */ + #listAccountAssets(accountId: string): CaipAssetType[] { + // TODO: Add an action `MultichainAssetsController:getAccountAssets` maybe? + const assetsState = this.messagingSystem.call( + 'MultichainAssetsController:getState', + ); + + return assetsState.accountsAssets[accountId] ?? []; + } + /** * Get a non-EVM account from its ID. * @@ -252,32 +289,6 @@ export class MultichainBalancesController extends BaseController< return account; } - /** - * Updates the balances of one account. This method doesn't return - * anything, but it updates the state of the controller. - * - * @param accountId - The account ID. - */ - - async #updateBalance(accountId: string) { - const account = this.#getAccount(accountId); - - if (account.metadata.snap) { - const scope = getScopeForAccount(account); - const assetTypes = NETWORK_ASSETS_MAP[scope]; - - const accountBalance = await this.#getBalances( - account.id, - account.metadata.snap.id, - assetTypes, - ); - - this.update((state: Draft) => { - state.balances[accountId] = accountBalance; - }); - } - } - /** * Checks for non-EVM accounts. * @@ -293,24 +304,22 @@ export class MultichainBalancesController extends BaseController< } /** - * Handles changes when a new account has been added. + * Handles balance updates received from the AccountsController. * - * @param account - The new account being added. + * @param balanceUpdate - The balance update event containing new balances. */ - async #handleOnAccountAdded(account: InternalAccount): Promise { - if (!this.#isNonEvmAccount(account)) { - // Nothing to do here for EVM accounts - return; - } - - this.#tracker.track(account.id, getBlockTimeForAccount(account.type)); - // NOTE: Unfortunately, we cannot update the balance right away here, because - // messenger's events are running synchronously and fetching the balance is - // asynchronous. - // Updating the balance here would resume at some point but the event emitter - // will not `await` this (so we have no real control "when" the balance will - // really be updated), see: - // - https://github.com/MetaMask/core/blob/v213.0.0/packages/accounts-controller/src/AccountsController.ts#L1036-L1039 + #handleOnAccountBalancesUpdated( + balanceUpdate: AccountBalancesUpdatedEventPayload, + ): void { + this.update((state: Draft) => { + Object.entries(balanceUpdate.balances).forEach( + ([accountId, assetBalances]) => { + if (accountId in state.balances) { + Object.assign(state.balances[accountId], assetBalances); + } + }, + ); + }); } /** @@ -319,10 +328,6 @@ export class MultichainBalancesController extends BaseController< * @param accountId - The account ID being removed. */ async #handleOnAccountRemoved(accountId: string): Promise { - if (this.#tracker.isTracked(accountId)) { - this.#tracker.untrack(accountId); - } - if (accountId in this.state.balances) { this.update((state: Draft) => { delete state.balances[accountId]; diff --git a/packages/assets-controllers/src/MultichainBalancesController/Poller.test.ts b/packages/assets-controllers/src/MultichainBalancesController/Poller.test.ts deleted file mode 100644 index aba0e4041ba..00000000000 --- a/packages/assets-controllers/src/MultichainBalancesController/Poller.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { PollerError } from './error'; -import { Poller } from './Poller'; - -jest.useFakeTimers(); - -const interval = 1000; -const intervalPlus100ms = interval + 100; - -describe('Poller', () => { - let callback: jest.Mock, []>; - - beforeEach(() => { - callback = jest.fn().mockResolvedValue(undefined); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('calls the callback function after the specified interval', async () => { - const poller = new Poller(callback, interval); - poller.start(); - jest.advanceTimersByTime(intervalPlus100ms); - poller.stop(); - - // Wait for all promises to resolve - await Promise.resolve(); - - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('does not call the callback function if stopped before the interval', async () => { - const poller = new Poller(callback, interval); - poller.start(); - poller.stop(); - jest.advanceTimersByTime(intervalPlus100ms); - - // Wait for all promises to resolve - await Promise.resolve(); - - expect(callback).not.toHaveBeenCalled(); - }); - - it('calls the callback function multiple times if started and stopped multiple times', async () => { - const poller = new Poller(callback, interval); - poller.start(); - jest.advanceTimersByTime(intervalPlus100ms); - poller.stop(); - jest.advanceTimersByTime(intervalPlus100ms); - poller.start(); - jest.advanceTimersByTime(intervalPlus100ms); - poller.stop(); - - // Wait for all promises to resolve - await Promise.resolve(); - - expect(callback).toHaveBeenCalledTimes(2); - }); - - it('does not call the callback if the poller is stopped before the interval has passed', async () => { - const poller = new Poller(callback, interval); - poller.start(); - // Wait for some time, but stop before reaching the `interval` timeout - jest.advanceTimersByTime(interval / 2); - poller.stop(); - - // Wait for all promises to resolve - await Promise.resolve(); - - expect(callback).not.toHaveBeenCalled(); - }); - - it('does not start a new interval if already running', async () => { - const poller = new Poller(callback, interval); - poller.start(); - poller.start(); // Attempt to start again - jest.advanceTimersByTime(intervalPlus100ms); - poller.stop(); - - // Wait for all promises to resolve - await Promise.resolve(); - - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('can stop multiple times without issues', async () => { - const poller = new Poller(callback, interval); - poller.start(); - jest.advanceTimersByTime(interval / 2); - poller.stop(); - poller.stop(); // Attempt to stop again - jest.advanceTimersByTime(intervalPlus100ms); - - // Wait for all promises to resolve - await Promise.resolve(); - - expect(callback).not.toHaveBeenCalled(); - }); - - it('catches and logs a PollerError when callback throws an error', async () => { - const mockCallback = jest.fn().mockRejectedValue(new Error('Test error')); - const poller = new Poller(mockCallback, 1000); - const spyConsoleError = jest.spyOn(console, 'error'); - - poller.start(); - - // Fast-forward time to trigger the interval - jest.advanceTimersByTime(1000); - - // Wait for the promise to be handled - await Promise.resolve(); - - expect(mockCallback).toHaveBeenCalled(); - expect(spyConsoleError).toHaveBeenCalledWith(new PollerError('Test error')); - - poller.stop(); - }); -}); diff --git a/packages/assets-controllers/src/MultichainBalancesController/Poller.ts b/packages/assets-controllers/src/MultichainBalancesController/Poller.ts deleted file mode 100644 index c0167790c8d..00000000000 --- a/packages/assets-controllers/src/MultichainBalancesController/Poller.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { PollerError } from './error'; - -export class Poller { - #interval: number; - - #callback: () => Promise; - - #handle: NodeJS.Timeout | undefined = undefined; - - constructor(callback: () => Promise, interval: number) { - this.#interval = interval; - this.#callback = callback; - } - - start() { - if (this.#handle) { - return; - } - - this.#handle = setInterval(() => { - this.#callback().catch((err) => { - console.error(new PollerError(err.message)); - }); - }, this.#interval); - } - - stop() { - if (!this.#handle) { - return; - } - clearInterval(this.#handle); - this.#handle = undefined; - } -} diff --git a/packages/assets-controllers/src/MultichainBalancesController/constants.ts b/packages/assets-controllers/src/MultichainBalancesController/constants.ts deleted file mode 100644 index 81aebf8fbf8..00000000000 --- a/packages/assets-controllers/src/MultichainBalancesController/constants.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { BtcAccountType, SolAccountType } from '@metamask/keyring-api'; - -/** - * The network identifiers for supported networks in CAIP-2 format. - * Note: This is a temporary workaround until we have a more robust - * solution for network identifiers. - */ -export enum MultichainNetworks { - Bitcoin = 'bip122:000000000019d6689c085ae165831e93', - BitcoinTestnet = 'bip122:000000000933ea01ad0ee984209779ba', - Solana = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', - SolanaDevnet = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', - SolanaTestnet = 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z', -} - -export enum MultichainNativeAssets { - Bitcoin = `${MultichainNetworks.Bitcoin}/slip44:0`, - BitcoinTestnet = `${MultichainNetworks.BitcoinTestnet}/slip44:0`, - Solana = `${MultichainNetworks.Solana}/slip44:501`, - SolanaDevnet = `${MultichainNetworks.SolanaDevnet}/slip44:501`, - SolanaTestnet = `${MultichainNetworks.SolanaTestnet}/slip44:501`, -} - -const BITCOIN_AVG_BLOCK_TIME = 10 * 60 * 1000; // 10 minutes in milliseconds -const SOLANA_AVG_BLOCK_TIME = 400; // 400 milliseconds - -export const BALANCE_UPDATE_INTERVALS = { - // NOTE: We set an interval of half the average block time for bitcoin - // to mitigate when our interval is de-synchronized with the actual block time. - [BtcAccountType.P2wpkh]: BITCOIN_AVG_BLOCK_TIME / 2, - [SolAccountType.DataAccount]: SOLANA_AVG_BLOCK_TIME, -}; - -/** - * Maps network identifiers to their corresponding native asset types. - * Each network is mapped to an array containing its native asset for consistency. - */ -export const NETWORK_ASSETS_MAP: Record = { - [MultichainNetworks.Solana]: [MultichainNativeAssets.Solana], - [MultichainNetworks.SolanaTestnet]: [MultichainNativeAssets.SolanaTestnet], - [MultichainNetworks.SolanaDevnet]: [MultichainNativeAssets.SolanaDevnet], - [MultichainNetworks.Bitcoin]: [MultichainNativeAssets.Bitcoin], - [MultichainNetworks.BitcoinTestnet]: [MultichainNativeAssets.BitcoinTestnet], -}; diff --git a/packages/assets-controllers/src/MultichainBalancesController/error.test.ts b/packages/assets-controllers/src/MultichainBalancesController/error.test.ts deleted file mode 100644 index d94b5a37125..00000000000 --- a/packages/assets-controllers/src/MultichainBalancesController/error.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { BalancesTrackerError, PollerError } from './error'; - -describe('BalancesTrackerError', () => { - it('creates an instance of BalancesTrackerError with the correct message and name', () => { - const message = 'Test BalancesTrackerError message'; - const error = new BalancesTrackerError(message); - - expect(error).toBeInstanceOf(BalancesTrackerError); - expect(error.message).toBe(message); - expect(error.name).toBe('BalancesTrackerError'); - }); -}); - -describe('PollerError', () => { - it('creates an instance of PollerError with the correct message and name', () => { - const message = 'Test PollerError message'; - const error = new PollerError(message); - - expect(error).toBeInstanceOf(PollerError); - expect(error.message).toBe(message); - expect(error.name).toBe('PollerError'); - }); -}); diff --git a/packages/assets-controllers/src/MultichainBalancesController/error.ts b/packages/assets-controllers/src/MultichainBalancesController/error.ts deleted file mode 100644 index 22229fb8e80..00000000000 --- a/packages/assets-controllers/src/MultichainBalancesController/error.ts +++ /dev/null @@ -1,13 +0,0 @@ -export class BalancesTrackerError extends Error { - constructor(message: string) { - super(message); - this.name = 'BalancesTrackerError'; - } -} - -export class PollerError extends Error { - constructor(message: string) { - super(message); - this.name = 'PollerError'; - } -} diff --git a/packages/assets-controllers/src/MultichainBalancesController/index.ts b/packages/assets-controllers/src/MultichainBalancesController/index.ts index 4b000464b17..7e7b30a0950 100644 --- a/packages/assets-controllers/src/MultichainBalancesController/index.ts +++ b/packages/assets-controllers/src/MultichainBalancesController/index.ts @@ -1,15 +1,7 @@ -export { BalancesTracker } from './BalancesTracker'; export { MultichainBalancesController } from './MultichainBalancesController'; -export { - BALANCE_UPDATE_INTERVALS, - NETWORK_ASSETS_MAP, - MultichainNetworks, - MultichainNativeAssets, -} from './constants'; export type { MultichainBalancesControllerState, MultichainBalancesControllerGetStateAction, - MultichainBalancesControllerUpdateBalancesAction, MultichainBalancesControllerStateChange, MultichainBalancesControllerActions, MultichainBalancesControllerEvents, diff --git a/packages/assets-controllers/src/MultichainBalancesController/utils.test.ts b/packages/assets-controllers/src/MultichainBalancesController/utils.test.ts deleted file mode 100644 index 099ccf23c80..00000000000 --- a/packages/assets-controllers/src/MultichainBalancesController/utils.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { - BtcAccountType, - SolAccountType, - BtcMethod, - SolMethod, - BtcScopes, - SolScopes, -} from '@metamask/keyring-api'; -import { KeyringTypes } from '@metamask/keyring-controller'; -import { validate, Network } from 'bitcoin-address-validation'; -import { v4 as uuidv4 } from 'uuid'; - -import { MultichainNetworks, BALANCE_UPDATE_INTERVALS } from '.'; -import { - getScopeForBtcAddress, - getScopeForSolAddress, - getScopeForAccount, - getBlockTimeForAccount, -} from './utils'; - -const mockBtcAccount = { - address: 'bc1qssdcp5kvwh6nghzg9tuk99xsflwkdv4hgvq58q', - id: uuidv4(), - metadata: { - name: 'Bitcoin Account 1', - importTime: Date.now(), - keyring: { - type: KeyringTypes.snap, - }, - snap: { - id: 'mock-btc-snap', - name: 'mock-btc-snap', - enabled: true, - }, - lastSelected: 0, - }, - scopes: [BtcScopes.Namespace], - options: {}, - methods: [BtcMethod.SendBitcoin], - type: BtcAccountType.P2wpkh, -}; - -const mockSolAccount = { - address: 'nicktrLHhYzLmoVbuZQzHUTicd2sfP571orwo9jfc8c', - id: uuidv4(), - metadata: { - name: 'Solana Account 1', - importTime: Date.now(), - keyring: { - type: KeyringTypes.snap, - }, - snap: { - id: 'mock-sol-snap', - name: 'mock-sol-snap', - enabled: true, - }, - lastSelected: 0, - }, - options: { - scope: 'solana-scope', - }, - scopes: [SolScopes.Namespace], - methods: [SolMethod.SendAndConfirmTransaction], - type: SolAccountType.DataAccount, -}; - -jest.mock('bitcoin-address-validation', () => ({ - validate: jest.fn(), - Network: { - mainnet: 'mainnet', - testnet: 'testnet', - }, -})); - -describe('getScopeForBtcAddress', () => { - it('returns Bitcoin scope for a valid mainnet address', () => { - const account = { - ...mockBtcAccount, - address: 'valid-mainnet-address', - }; - (validate as jest.Mock).mockReturnValueOnce(true); - - const scope = getScopeForBtcAddress(account); - - expect(scope).toBe(MultichainNetworks.Bitcoin); - expect(validate).toHaveBeenCalledWith(account.address, Network.mainnet); - }); - - it('returns BitcoinTestnet scope for a valid testnet address', () => { - const account = { - ...mockBtcAccount, - address: 'valid-testnet-address', - }; - (validate as jest.Mock) - .mockReturnValueOnce(false) - .mockReturnValueOnce(true); - - const scope = getScopeForBtcAddress(account); - - expect(scope).toBe(MultichainNetworks.BitcoinTestnet); - expect(validate).toHaveBeenCalledWith(account.address, Network.mainnet); - expect(validate).toHaveBeenCalledWith(account.address, Network.testnet); - }); - - it('throws an error for an invalid address', () => { - const account = { - ...mockBtcAccount, - address: 'invalid-address', - }; - (validate as jest.Mock) - .mockReturnValueOnce(false) - .mockReturnValueOnce(false); - - expect(() => getScopeForBtcAddress(account)).toThrow( - `Invalid Bitcoin address: ${account.address}`, - ); - expect(validate).toHaveBeenCalledWith(account.address, Network.mainnet); - expect(validate).toHaveBeenCalledWith(account.address, Network.testnet); - }); -}); - -describe('getScopeForSolAddress', () => { - it('returns the scope for a valid Solana account', () => { - const scope = getScopeForSolAddress(mockSolAccount); - - expect(scope).toBe('solana-scope'); - }); - - it('throws an error if the Solana account scope is undefined', () => { - const account = { - ...mockSolAccount, - options: {}, - }; - - expect(() => getScopeForSolAddress(account)).toThrow( - 'Solana account scope is undefined', - ); - }); -}); - -describe('getScopeForAddress', () => { - it('returns the scope for a Bitcoin account', () => { - const account = { - ...mockBtcAccount, - address: 'valid-mainnet-address', - }; - (validate as jest.Mock).mockReturnValueOnce(true); - - const scope = getScopeForAccount(account); - - expect(scope).toBe(MultichainNetworks.Bitcoin); - }); - - it('returns the scope for a Solana account', () => { - const account = { - ...mockSolAccount, - options: { scope: 'solana-scope' }, - }; - - const scope = getScopeForAccount(account); - - expect(scope).toBe('solana-scope'); - }); - - it('throws an error for an unsupported account type', () => { - const account = { - ...mockSolAccount, - type: 'unsupported-type', - }; - - // @ts-expect-error - We're testing an error case. - expect(() => getScopeForAccount(account)).toThrow( - `Unsupported non-EVM account type: ${account.type}`, - ); - }); -}); - -describe('getBlockTimeForAccount', () => { - it('returns the block time for a supported Bitcoin account', () => { - const blockTime = getBlockTimeForAccount(BtcAccountType.P2wpkh); - expect(blockTime).toBe(BALANCE_UPDATE_INTERVALS[BtcAccountType.P2wpkh]); - }); - - it('returns the block time for a supported Solana account', () => { - const blockTime = getBlockTimeForAccount(SolAccountType.DataAccount); - expect(blockTime).toBe( - BALANCE_UPDATE_INTERVALS[SolAccountType.DataAccount], - ); - }); - - it('throws an error for an unsupported account type', () => { - const unsupportedAccountType = 'unsupported-type'; - expect(() => getBlockTimeForAccount(unsupportedAccountType)).toThrow( - `Unsupported account type for balance tracking: ${unsupportedAccountType}`, - ); - }); -}); diff --git a/packages/assets-controllers/src/MultichainBalancesController/utils.ts b/packages/assets-controllers/src/MultichainBalancesController/utils.ts deleted file mode 100644 index 205cca8fc33..00000000000 --- a/packages/assets-controllers/src/MultichainBalancesController/utils.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { BtcAccountType, SolAccountType } from '@metamask/keyring-api'; -import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { validate, Network } from 'bitcoin-address-validation'; - -import { MultichainNetworks, BALANCE_UPDATE_INTERVALS } from './constants'; - -/** - * Gets the scope for a specific and supported Bitcoin account. - * Note: This is a temporary method and will be replaced by a more robust solution - * once the new `account.scopes` is available in the `@metamask/keyring-api` module. - * - * @param account - Bitcoin account - * @returns The scope for the given account. - */ -export const getScopeForBtcAddress = (account: InternalAccount): string => { - if (validate(account.address, Network.mainnet)) { - return MultichainNetworks.Bitcoin; - } - - if (validate(account.address, Network.testnet)) { - return MultichainNetworks.BitcoinTestnet; - } - - throw new Error(`Invalid Bitcoin address: ${account.address}`); -}; - -/** - * Gets the scope for a specific and supported Solana account. - * Note: This is a temporary method and will be replaced by a more robust solution - * once the new `account.scopes` is available in the `keyring-api`. - * - * @param account - Solana account - * @returns The scope for the given account. - */ -export const getScopeForSolAddress = (account: InternalAccount): string => { - // For Solana accounts, we know we have a `scope` on the account's `options` bag. - if (!account.options.scope) { - throw new Error('Solana account scope is undefined'); - } - return account.options.scope as string; -}; - -/** - * Get the scope for a given address. - * Note: This is a temporary method and will be replaced by a more robust solution - * once the new `account.scopes` is available in the `keyring-api`. - * - * @param account - The account to get the scope for. - * @returns The scope for the given account. - */ -export const getScopeForAccount = (account: InternalAccount): string => { - switch (account.type) { - case BtcAccountType.P2wpkh: - return getScopeForBtcAddress(account); - case SolAccountType.DataAccount: - return getScopeForSolAddress(account); - default: - throw new Error(`Unsupported non-EVM account type: ${account.type}`); - } -}; - -/** - * Gets the block time for a given account. - * - * @param accountType - The account type to get the block time for. - * @returns The block time for the account. - */ -export const getBlockTimeForAccount = (accountType: string): number => { - if (accountType in BALANCE_UPDATE_INTERVALS) { - return BALANCE_UPDATE_INTERVALS[ - accountType as keyof typeof BALANCE_UPDATE_INTERVALS - ]; - } - throw new Error( - `Unsupported account type for balance tracking: ${accountType}`, - ); -}; diff --git a/packages/assets-controllers/src/NftController.test.ts b/packages/assets-controllers/src/NftController.test.ts index 49e48178c7a..c04a9174516 100644 --- a/packages/assets-controllers/src/NftController.test.ts +++ b/packages/assets-controllers/src/NftController.test.ts @@ -6,7 +6,7 @@ import type { } from '@metamask/accounts-controller'; import type { ApprovalControllerMessenger } from '@metamask/approval-controller'; import { ApprovalController } from '@metamask/approval-controller'; -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { IPFS_DEFAULT_GATEWAY_URL, ERC1155, @@ -203,7 +203,7 @@ function setupController({ >; defaultSelectedAccount?: InternalAccount; } = {}) { - const messenger = new ControllerMessenger< + const messenger = new Messenger< | ExtractAvailableAction | NftControllerAllowedActions | ExtractAvailableAction, diff --git a/packages/assets-controllers/src/NftController.ts b/packages/assets-controllers/src/NftController.ts index 292611dc0d7..552bb07c272 100644 --- a/packages/assets-controllers/src/NftController.ts +++ b/packages/assets-controllers/src/NftController.ts @@ -6,7 +6,7 @@ import type { } from '@metamask/accounts-controller'; import type { AddApprovalRequest } from '@metamask/approval-controller'; import type { - RestrictedControllerMessenger, + RestrictedMessenger, ControllerStateChangeEvent, } from '@metamask/base-controller'; import { @@ -259,7 +259,7 @@ export type NftControllerEvents = NftControllerStateChangeEvent; /** * The messenger of the {@link NftController}. */ -export type NftControllerMessenger = RestrictedControllerMessenger< +export type NftControllerMessenger = RestrictedMessenger< typeof controllerName, NftControllerActions | AllowedActions, NftControllerEvents | AllowedEvents, @@ -321,7 +321,7 @@ export class NftController extends BaseController< * @param options.isIpfsGatewayEnabled - Controls whether IPFS is enabled or not. * @param options.onNftAdded - Callback that is called when an NFT is added. Currently used pass data * for tracking the NFT added event. - * @param options.messenger - The controller messenger. + * @param options.messenger - The messenger. * @param options.state - Initial state to set on this controller. */ constructor({ diff --git a/packages/assets-controllers/src/NftDetectionController.test.ts b/packages/assets-controllers/src/NftDetectionController.test.ts index 85c6588b8aa..e730c9d02e3 100644 --- a/packages/assets-controllers/src/NftDetectionController.test.ts +++ b/packages/assets-controllers/src/NftDetectionController.test.ts @@ -1,5 +1,5 @@ import type { AccountsController } from '@metamask/accounts-controller'; -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { NFT_API_BASE_URL, ChainId, @@ -1650,7 +1650,7 @@ async function withController( testFunction, ] = args.length === 2 ? args : [{}, args[0]]; - const messenger = new ControllerMessenger(); + const messenger = new Messenger(); messenger.registerActionHandler( 'NetworkController:getState', @@ -1681,22 +1681,20 @@ async function withController( }), ); - const controllerMessenger = messenger.getRestricted({ - name: controllerName, - allowedActions: [ - 'NetworkController:getState', - 'NetworkController:getNetworkClientById', - 'PreferencesController:getState', - 'AccountsController:getSelectedAccount', - ], - allowedEvents: [ - 'NetworkController:stateChange', - 'PreferencesController:stateChange', - ], - }); - const controller = new NftDetectionController({ - messenger: controllerMessenger, + messenger: messenger.getRestricted({ + name: controllerName, + allowedActions: [ + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'PreferencesController:getState', + 'AccountsController:getSelectedAccount', + ], + allowedEvents: [ + 'NetworkController:stateChange', + 'PreferencesController:stateChange', + ], + }), disabled: true, addNft: jest.fn(), getNftState: getDefaultNftControllerState, diff --git a/packages/assets-controllers/src/NftDetectionController.ts b/packages/assets-controllers/src/NftDetectionController.ts index e1ed6f83b8c..34fc6dc7631 100644 --- a/packages/assets-controllers/src/NftDetectionController.ts +++ b/packages/assets-controllers/src/NftDetectionController.ts @@ -1,6 +1,6 @@ import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; import type { AddApprovalRequest } from '@metamask/approval-controller'; -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; +import type { RestrictedMessenger } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import { toChecksumHexAddress, @@ -49,7 +49,7 @@ export type AllowedEvents = | PreferencesControllerStateChangeEvent | NetworkControllerStateChangeEvent; -export type NftDetectionControllerMessenger = RestrictedControllerMessenger< +export type NftDetectionControllerMessenger = RestrictedMessenger< typeof controllerName, AllowedActions, AllowedEvents, diff --git a/packages/assets-controllers/src/RatesController/RatesController.test.ts b/packages/assets-controllers/src/RatesController/RatesController.test.ts index f2fca24f76a..93b04c5f416 100644 --- a/packages/assets-controllers/src/RatesController/RatesController.test.ts +++ b/packages/assets-controllers/src/RatesController/RatesController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { useFakeTimers } from 'sinon'; import { advanceTime } from '../../../../tests/helpers'; @@ -26,17 +26,14 @@ function getStubbedDate(): number { } /** - * Builds a new ControllerMessenger instance for RatesController. - * @returns A new ControllerMessenger instance. + * Builds a new Messenger instance for RatesController. + * @returns A new Messenger instance. */ -function buildMessenger(): ControllerMessenger< +function buildMessenger(): Messenger< RatesControllerActions, RatesControllerEvents > { - return new ControllerMessenger< - RatesControllerActions, - RatesControllerEvents - >(); + return new Messenger(); } /** @@ -45,7 +42,7 @@ function buildMessenger(): ControllerMessenger< * @returns A restricted messenger for the RatesController. */ function buildRatesControllerMessenger( - messenger: ControllerMessenger, + messenger: Messenger, ): RatesControllerMessenger { return messenger.getRestricted({ name: ratesControllerName, @@ -59,7 +56,7 @@ function buildRatesControllerMessenger( * @param config - The configuration object for the RatesController. * @param config.interval - Polling interval. * @param config.initialState - Initial state of the controller. - * @param config.messenger - ControllerMessenger instance. + * @param config.messenger - Messenger instance. * @param config.includeUsdRate - Indicates if the USD rate should be included. * @param config.fetchMultiExchangeRate - Callback to fetch rates data. * @returns A new instance of RatesController. @@ -73,7 +70,7 @@ function setupRatesController({ }: { interval?: number; initialState: Partial; - messenger: ControllerMessenger; + messenger: Messenger; includeUsdRate: boolean; fetchMultiExchangeRate?: typeof defaultFetchExchangeRate; }) { diff --git a/packages/assets-controllers/src/RatesController/types.ts b/packages/assets-controllers/src/RatesController/types.ts index ba8ad5aa377..c26ae070075 100644 --- a/packages/assets-controllers/src/RatesController/types.ts +++ b/packages/assets-controllers/src/RatesController/types.ts @@ -1,5 +1,5 @@ import type { - RestrictedControllerMessenger, + RestrictedMessenger, ControllerGetStateAction, ControllerStateChangeEvent, } from '@metamask/base-controller'; @@ -97,7 +97,7 @@ export type RatesControllerActions = RatesControllerGetStateAction; /** * Defines the actions that the RatesController can perform. */ -export type RatesControllerMessenger = RestrictedControllerMessenger< +export type RatesControllerMessenger = RestrictedMessenger< typeof ratesControllerName, RatesControllerActions, RatesControllerEvents, diff --git a/packages/assets-controllers/src/TokenBalancesController.test.ts b/packages/assets-controllers/src/TokenBalancesController.test.ts index 0be4e661164..0d137b71392 100644 --- a/packages/assets-controllers/src/TokenBalancesController.test.ts +++ b/packages/assets-controllers/src/TokenBalancesController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { toHex } from '@metamask/controller-utils'; import type { NetworkState } from '@metamask/network-controller'; import type { PreferencesState } from '@metamask/preferences-controller'; @@ -24,7 +24,7 @@ const setupController = ({ config?: Partial[0]>; tokens?: Partial; } = {}) => { - const messenger = new ControllerMessenger< + const messenger = new Messenger< TokenBalancesControllerActions | AllowedActions, TokenBalancesControllerEvents | AllowedEvents >(); diff --git a/packages/assets-controllers/src/TokenBalancesController.ts b/packages/assets-controllers/src/TokenBalancesController.ts index 96c89392ac3..5c57b3eabe2 100644 --- a/packages/assets-controllers/src/TokenBalancesController.ts +++ b/packages/assets-controllers/src/TokenBalancesController.ts @@ -2,7 +2,7 @@ import { Contract } from '@ethersproject/contracts'; import { Web3Provider } from '@ethersproject/providers'; import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; import type { - RestrictedControllerMessenger, + RestrictedMessenger, ControllerGetStateAction, ControllerStateChangeEvent, } from '@metamask/base-controller'; @@ -45,7 +45,7 @@ const metadata = { /** * Token balances controller options * @property interval - Polling interval used to fetch new token balances. - * @property messenger - A controller messenger. + * @property messenger - A messenger. * @property state - Initial state for the controller. */ type TokenBalancesControllerOptions = { @@ -96,7 +96,7 @@ export type AllowedEvents = | PreferencesControllerStateChangeEvent | NetworkControllerStateChangeEvent; -export type TokenBalancesControllerMessenger = RestrictedControllerMessenger< +export type TokenBalancesControllerMessenger = RestrictedMessenger< typeof controllerName, TokenBalancesControllerActions | AllowedActions, TokenBalancesControllerEvents | AllowedEvents, diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index a084b5a0ac1..1c7350baea2 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -1,5 +1,5 @@ import type { AddApprovalRequest } from '@metamask/approval-controller'; -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { ChainId, NetworkType, @@ -147,20 +147,21 @@ const mockNetworkConfigurations: Record = { }, }; -type MainControllerMessenger = ControllerMessenger< +type MainMessenger = Messenger< AllowedActions | AddApprovalRequest, AllowedEvents >; /** * Builds a messenger that `TokenDetectionController` can use to communicate with other controllers. - * @param controllerMessenger - The main controller messenger. + * + * @param messenger - The main messenger. * @returns The restricted messenger. */ function buildTokenDetectionControllerMessenger( - controllerMessenger: MainControllerMessenger = new ControllerMessenger(), + messenger: MainMessenger = new Messenger(), ): TokenDetectionControllerMessenger { - return controllerMessenger.getRestricted({ + return messenger.getRestricted({ name: controllerName, allowedActions: [ 'AccountsController:getAccount', @@ -348,7 +349,7 @@ describe('TokenDetectionController', () => { () => ({ configuration: { chainId: '0x5' }, - } as unknown as AutoManagedNetworkClient), + }) as unknown as AutoManagedNetworkClient, ); await controller.start(); @@ -520,7 +521,7 @@ describe('TokenDetectionController', () => { () => ({ configuration: { chainId: '0x89' }, - } as unknown as AutoManagedNetworkClient), + }) as unknown as AutoManagedNetworkClient, ); mockTokenListGetState({ @@ -3070,7 +3071,6 @@ type WithControllerCallback = ({ type WithControllerOptions = { options?: Partial[0]>; isKeyringUnlocked?: boolean; - messenger?: ControllerMessenger; mocks?: { getAccount?: InternalAccount; getSelectedAccount?: InternalAccount; @@ -3094,12 +3094,11 @@ async function withController( ...args: WithControllerArgs ): Promise { const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; - const { options, isKeyringUnlocked, messenger, mocks } = rest; - const controllerMessenger = - messenger ?? new ControllerMessenger(); + const { options, isKeyringUnlocked, mocks } = rest; + const messenger = new Messenger(); const mockGetAccount = jest.fn(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'AccountsController:getAccount', mockGetAccount.mockReturnValue( mocks?.getAccount ?? createMockInternalAccount({ address: '0x1' }), @@ -3107,7 +3106,7 @@ async function withController( ); const mockGetSelectedAccount = jest.fn(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'AccountsController:getSelectedAccount', mockGetSelectedAccount.mockReturnValue( mocks?.getSelectedAccount ?? @@ -3115,7 +3114,7 @@ async function withController( ), ); const mockKeyringState = jest.fn(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'KeyringController:getState', mockKeyringState.mockReturnValue({ isUnlocked: isKeyringUnlocked ?? true, @@ -3125,7 +3124,7 @@ async function withController( ReturnType, Parameters >(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'NetworkController:getNetworkClientById', mockGetNetworkClientById.mockImplementation(() => { return { @@ -3140,7 +3139,7 @@ async function withController( ReturnType, Parameters >(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'NetworkController:getNetworkConfigurationByNetworkClientId', mockGetNetworkConfigurationByNetworkClientId.mockImplementation( (networkClientId: NetworkClientId) => { @@ -3149,28 +3148,28 @@ async function withController( ), ); const mockNetworkState = jest.fn(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'NetworkController:getState', mockNetworkState.mockReturnValue({ ...getDefaultNetworkControllerState() }), ); const mockTokensState = jest.fn(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'TokensController:getState', mockTokensState.mockReturnValue({ ...getDefaultTokensState() }), ); const mockTokenListState = jest.fn(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'TokenListController:getState', mockTokenListState.mockReturnValue({ ...getDefaultTokenListState() }), ); const mockPreferencesState = jest.fn(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'PreferencesController:getState', mockPreferencesState.mockReturnValue({ ...getDefaultPreferencesState(), }), ); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'TokensController:addDetectedTokens', jest .fn< @@ -3179,12 +3178,12 @@ async function withController( >() .mockResolvedValue(undefined), ); - const callActionSpy = jest.spyOn(controllerMessenger, 'call'); + const callActionSpy = jest.spyOn(messenger, 'call'); const controller = new TokenDetectionController({ getBalancesInSingleCall: jest.fn(), trackMetaMetricsEvent: jest.fn(), - messenger: buildTokenDetectionControllerMessenger(controllerMessenger), + messenger: buildTokenDetectionControllerMessenger(messenger), useAccountsAPI: false, platform: 'extension', ...options, @@ -3229,36 +3228,25 @@ async function withController( }, callActionSpy, triggerKeyringUnlock: () => { - controllerMessenger.publish('KeyringController:unlock'); + messenger.publish('KeyringController:unlock'); }, triggerKeyringLock: () => { - controllerMessenger.publish('KeyringController:lock'); + messenger.publish('KeyringController:lock'); }, triggerTokenListStateChange: (state: TokenListState) => { - controllerMessenger.publish( - 'TokenListController:stateChange', - state, - [], - ); + messenger.publish('TokenListController:stateChange', state, []); }, triggerPreferencesStateChange: (state: PreferencesState) => { - controllerMessenger.publish( - 'PreferencesController:stateChange', - state, - [], - ); + messenger.publish('PreferencesController:stateChange', state, []); }, triggerSelectedAccountChange: (account: InternalAccount) => { - controllerMessenger.publish( + messenger.publish( 'AccountsController:selectedEvmAccountChange', account, ); }, triggerNetworkDidChange: (state: NetworkState) => { - controllerMessenger.publish( - 'NetworkController:networkDidChange', - state, - ); + messenger.publish('NetworkController:networkDidChange', state); }, }); } finally { diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 031fcd4ec4f..2cc4a838ece 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -4,7 +4,7 @@ import type { AccountsControllerSelectedEvmAccountChangeEvent, } from '@metamask/accounts-controller'; import type { - RestrictedControllerMessenger, + RestrictedMessenger, ControllerGetStateAction, ControllerStateChangeEvent, } from '@metamask/base-controller'; @@ -144,7 +144,7 @@ export type AllowedEvents = | KeyringControllerUnlockEvent | PreferencesControllerStateChangeEvent; -export type TokenDetectionControllerMessenger = RestrictedControllerMessenger< +export type TokenDetectionControllerMessenger = RestrictedMessenger< typeof controllerName, TokenDetectionControllerActions | AllowedActions, TokenDetectionControllerEvents | AllowedEvents, @@ -836,7 +836,7 @@ export class TokenDetectionController extends StaticIntervalPollingController, ExtractAvailableEvent >; -const getControllerMessenger = (): MainControllerMessenger => { - return new ControllerMessenger(); +const getMessenger = (): MainMessenger => { + return new Messenger(); }; -const getRestrictedMessenger = ( - controllerMessenger: MainControllerMessenger, -) => { - const messenger = controllerMessenger.getRestricted({ +const getRestrictedMessenger = (messenger: MainMessenger) => { + return messenger.getRestricted({ name, allowedActions: ['NetworkController:getNetworkClientById'], allowedEvents: ['NetworkController:stateChange'], }); - - return messenger; }; describe('TokenListController', () => { @@ -522,12 +518,12 @@ describe('TokenListController', () => { }); it('should set default state', async () => { - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, }); expect(controller.state).toStrictEqual({ @@ -537,18 +533,16 @@ describe('TokenListController', () => { }); controller.destroy(); - controllerMessenger.clearEventSubscriptions( - 'NetworkController:stateChange', - ); + messenger.clearEventSubscriptions('NetworkController:stateChange'); }); it('should initialize with initial state', () => { - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, state: existingState, }); expect(controller.state).toStrictEqual({ @@ -586,17 +580,15 @@ describe('TokenListController', () => { }); controller.destroy(); - controllerMessenger.clearEventSubscriptions( - 'NetworkController:stateChange', - ); + messenger.clearEventSubscriptions('NetworkController:stateChange'); }); it('should initiate without preventPollingOnNetworkRestart', async () => { - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, - messenger, + messenger: restrictedMessenger, }); expect(controller.state).toStrictEqual({ @@ -609,13 +601,13 @@ describe('TokenListController', () => { }); it('should not poll before being started', async () => { - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, preventPollingOnNetworkRestart: false, interval: 100, - messenger, + messenger: restrictedMessenger, }); await new Promise((resolve) => setTimeout(() => resolve(), 150)); @@ -630,24 +622,24 @@ describe('TokenListController', () => { .reply(200, sampleMainnetTokenList) .persist(); const selectedNetworkClientId = 'selectedNetworkClientId'; - const controllerMessenger = getControllerMessenger(); + const messenger = getMessenger(); const getNetworkClientById = buildMockGetNetworkClientById({ [selectedNetworkClientId]: buildCustomNetworkClientConfiguration({ chainId: toHex(1337), }), }); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'NetworkController:getNetworkClientById', getNetworkClientById, ); - const messenger = getRestrictedMessenger(controllerMessenger); + const restrictedMessenger = getRestrictedMessenger(messenger); let onNetworkStateChangeCallback!: (state: NetworkState) => void; const controller = new TokenListController({ chainId: ChainId.mainnet, onNetworkStateChange: (cb) => (onNetworkStateChangeCallback = cb), preventPollingOnNetworkRestart: false, interval: 100, - messenger, + messenger: restrictedMessenger, }); // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -675,13 +667,13 @@ describe('TokenListController', () => { 'fetchTokenList', ); - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, preventPollingOnNetworkRestart: false, interval: 100, - messenger, + messenger: restrictedMessenger, }); await controller.start(); @@ -700,13 +692,13 @@ describe('TokenListController', () => { 'fetchTokenList', ); - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, preventPollingOnNetworkRestart: false, interval: 100, - messenger, + messenger: restrictedMessenger, }); await controller.start(); controller.stop(); @@ -727,14 +719,14 @@ describe('TokenListController', () => { 'fetchTokenList', ); - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, preventPollingOnNetworkRestart: false, interval: 100, - messenger, + messenger: restrictedMessenger, }); await controller.start(); controller.stop(); @@ -758,13 +750,13 @@ describe('TokenListController', () => { 'fetchTokenList', ); - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, preventPollingOnNetworkRestart: false, interval: 100, - messenger, + messenger: restrictedMessenger, }); await controller.start(); controller.stop(); @@ -780,13 +772,13 @@ describe('TokenListController', () => { 'fetchTokenList', ); - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.sepolia, preventPollingOnNetworkRestart: false, interval: 100, - messenger, + messenger: restrictedMessenger, }); await controller.start(); controller.stop(); @@ -804,12 +796,12 @@ describe('TokenListController', () => { .reply(200, sampleMainnetTokenList) .persist(); - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, interval: 750, }); await controller.start(); @@ -847,12 +839,12 @@ describe('TokenListController', () => { .reply(200, sampleMainnetTokenList) .persist(); - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, interval: 100, state: existingState, }); @@ -869,12 +861,12 @@ describe('TokenListController', () => { }); it('should update token list from cache before reaching the threshold time', async () => { - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, state: existingState, }); expect(controller.state).toStrictEqual(existingState); @@ -897,12 +889,12 @@ describe('TokenListController', () => { .reply(200, sampleMainnetTokenList) .persist(); - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, state: outdatedExistingState, }); expect(controller.state).toStrictEqual(outdatedExistingState); @@ -925,12 +917,12 @@ describe('TokenListController', () => { .reply(200, sampleMainnetTokenList) .persist(); - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, state: expiredCacheExistingState, }); expect(controller.state).toStrictEqual(expiredCacheExistingState); @@ -959,7 +951,7 @@ describe('TokenListController', () => { .reply(200, sampleBinanceTokenList) .persist(); const selectedCustomNetworkClientId = 'selectedCustomNetworkClientId'; - const controllerMessenger = getControllerMessenger(); + const messenger = getMessenger(); const getNetworkClientById = buildMockGetNetworkClientById({ [InfuraNetworkType.goerli]: buildInfuraNetworkClientConfiguration( InfuraNetworkType.goerli, @@ -968,15 +960,15 @@ describe('TokenListController', () => { chainId: toHex(56), }), }); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'NetworkController:getNetworkClientById', getNetworkClientById, ); - const messenger = getRestrictedMessenger(controllerMessenger); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, state: existingState, interval: 100, }); @@ -992,7 +984,7 @@ describe('TokenListController', () => { sampleTwoChainState.tokensChainsCache[ChainId.mainnet].data, ); - controllerMessenger.publish( + messenger.publish( 'NetworkController:stateChange', { selectedNetworkClientId: InfuraNetworkType.goerli, @@ -1013,7 +1005,7 @@ describe('TokenListController', () => { sampleTwoChainState.tokensChainsCache[ChainId.mainnet].data, ); - controllerMessenger.publish( + messenger.publish( 'NetworkController:stateChange', { selectedNetworkClientId: selectedCustomNetworkClientId, @@ -1044,12 +1036,12 @@ describe('TokenListController', () => { }); it('should clear the tokenList and tokensChainsCache', async () => { - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, state: existingState, }); expect(controller.state).toStrictEqual(existingState); @@ -1072,7 +1064,7 @@ describe('TokenListController', () => { .persist(); const selectedCustomNetworkClientId = 'selectedCustomNetworkClientId'; - const controllerMessenger = getControllerMessenger(); + const messenger = getMessenger(); const getNetworkClientById = buildMockGetNetworkClientById({ [InfuraNetworkType.mainnet]: buildInfuraNetworkClientConfiguration( InfuraNetworkType.mainnet, @@ -1081,19 +1073,19 @@ describe('TokenListController', () => { chainId: toHex(56), }), }); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'NetworkController:getNetworkClientById', getNetworkClientById, ); - const messenger = getRestrictedMessenger(controllerMessenger); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.goerli, preventPollingOnNetworkRestart: true, - messenger, + messenger: restrictedMessenger, interval: 100, }); await controller.start(); - controllerMessenger.publish( + messenger.publish( 'NetworkController:stateChange', { selectedNetworkClientId: InfuraNetworkType.mainnet, @@ -1139,8 +1131,8 @@ describe('TokenListController', () => { tokenService, 'fetchTokenListByChainId', ); - const controllerMessenger = getControllerMessenger(); - controllerMessenger.registerActionHandler( + const messenger = getMessenger(); + messenger.registerActionHandler( 'NetworkController:getNetworkClientById', jest.fn().mockReturnValue({ configuration: { @@ -1149,11 +1141,11 @@ describe('TokenListController', () => { }, }), ); - const messenger = getRestrictedMessenger(controllerMessenger); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, state: expiredCacheExistingState, interval: pollingIntervalTime, }); @@ -1189,8 +1181,8 @@ describe('TokenListController', () => { } }); - const controllerMessenger = getControllerMessenger(); - controllerMessenger.registerActionHandler( + const messenger = getMessenger(); + messenger.registerActionHandler( 'NetworkController:getNetworkClientById', jest.fn().mockImplementation((networkClientId) => { switch (networkClientId) { @@ -1213,11 +1205,11 @@ describe('TokenListController', () => { } }), ); - const messenger = getRestrictedMessenger(controllerMessenger); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.sepolia, preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, state: startingState, interval: pollingIntervalTime, }); diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index 7f5e373777c..bf2bb7ea557 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -1,7 +1,7 @@ import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { safelyExecute } from '@metamask/controller-utils'; import type { @@ -69,7 +69,7 @@ type AllowedActions = NetworkControllerGetNetworkClientByIdAction; type AllowedEvents = NetworkControllerStateChangeEvent; -export type TokenListControllerMessenger = RestrictedControllerMessenger< +export type TokenListControllerMessenger = RestrictedMessenger< typeof name, TokenListControllerActions | AllowedActions, TokenListControllerEvents | AllowedEvents, @@ -124,7 +124,7 @@ export class TokenListController extends StaticIntervalPollingController; /** * Builds a messenger that `TokenRatesController` can use to communicate with other controllers. - * @param controllerMessenger - The main controller messenger. + * + * @param messenger - The main messenger. * @returns The restricted messenger. */ function buildTokenRatesControllerMessenger( - controllerMessenger: MainControllerMessenger = new ControllerMessenger(), + messenger: MainMessenger = new Messenger(), ): TokenRatesControllerMessenger { - return controllerMessenger.getRestricted({ + return messenger.getRestricted({ name: controllerName, allowedActions: [ 'TokensController:getState', @@ -2093,11 +2094,9 @@ describe('TokenRatesController', () => { price: 0.002, }, }), - validateCurrencySupported: jest.fn().mockReturnValue( - false, - // Cast used because this method has an assertion in the return - // value that I don't know how to type properly with Jest's mock. - ) as unknown as AbstractTokenPricesService['validateCurrencySupported'], + validateCurrencySupported(_currency: unknown): _currency is string { + return false; + }, }); nock('https://min-api.cryptocompare.com') .get('/data/price') @@ -2288,11 +2287,9 @@ describe('TokenRatesController', () => { value: 0.002, }, }), - validateChainIdSupported: jest.fn().mockReturnValue( - false, - // Cast used because this method has an assertion in the return - // value that I don't know how to type properly with Jest's mock. - ) as unknown as AbstractTokenPricesService['validateChainIdSupported'], + validateChainIdSupported(_chainId: unknown): _chainId is Hex { + return false; + }, }); await withController( { @@ -2553,7 +2550,6 @@ type WithControllerCallback = ({ type WithControllerOptions = { options?: Partial[0]>; - messenger?: ControllerMessenger; mockNetworkClientConfigurationsByNetworkClientId?: Record< NetworkClientId, NetworkClientConfiguration @@ -2581,16 +2577,14 @@ async function withController( const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; const { options, - messenger, mockNetworkClientConfigurationsByNetworkClientId, mockTokensControllerState, mockNetworkState, } = rest; - const controllerMessenger = - messenger ?? new ControllerMessenger(); + const messenger = new Messenger(); const mockTokensState = jest.fn(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'TokensController:getState', mockTokensState.mockReturnValue({ ...getDefaultTokensState(), @@ -2601,13 +2595,13 @@ async function withController( const getNetworkClientById = buildMockGetNetworkClientById( mockNetworkClientConfigurationsByNetworkClientId, ); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'NetworkController:getNetworkClientById', getNetworkClientById, ); const networkStateMock = jest.fn(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'NetworkController:getState', networkStateMock.mockReturnValue({ ...getDefaultNetworkControllerState(), @@ -2616,44 +2610,40 @@ async function withController( ); const mockGetSelectedAccount = jest.fn(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'AccountsController:getSelectedAccount', mockGetSelectedAccount.mockReturnValue(defaultSelectedAccount), ); const mockGetAccount = jest.fn(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'AccountsController:getAccount', mockGetAccount.mockReturnValue(defaultSelectedAccount), ); const controller = new TokenRatesController({ tokenPricesService: buildMockTokenPricesService(), - messenger: buildTokenRatesControllerMessenger(controllerMessenger), + messenger: buildTokenRatesControllerMessenger(messenger), ...options, }); try { return await fn({ controller, triggerSelectedAccountChange: (account: InternalAccount) => { - controllerMessenger.publish( + messenger.publish( 'AccountsController:selectedEvmAccountChange', account, ); }, triggerTokensStateChange: (state: TokensControllerState) => { - controllerMessenger.publish('TokensController:stateChange', state, []); + messenger.publish('TokensController:stateChange', state, []); }, triggerNetworkStateChange: ( state: NetworkState, patches: Patch[] = [], ) => { - controllerMessenger.publish( - 'NetworkController:stateChange', - state, - patches, - ); + messenger.publish('NetworkController:stateChange', state, patches); }, }); } finally { diff --git a/packages/assets-controllers/src/TokenRatesController.ts b/packages/assets-controllers/src/TokenRatesController.ts index f25702bbf82..ea910c1cf88 100644 --- a/packages/assets-controllers/src/TokenRatesController.ts +++ b/packages/assets-controllers/src/TokenRatesController.ts @@ -6,7 +6,7 @@ import type { import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { safelyExecute, @@ -159,7 +159,7 @@ export type TokenRatesControllerEvents = TokenRatesControllerStateChangeEvent; /** * The messenger of the {@link TokenRatesController} for communication. */ -export type TokenRatesControllerMessenger = RestrictedControllerMessenger< +export type TokenRatesControllerMessenger = RestrictedMessenger< typeof controllerName, TokenRatesControllerActions | AllowedActions, TokenRatesControllerEvents | AllowedEvents, @@ -238,7 +238,7 @@ export class TokenRatesController extends StaticIntervalPollingController> = {}; @@ -263,7 +263,7 @@ export class TokenRatesController extends StaticIntervalPollingController ({ jest.mock('./Standards/ERC20Standard'); jest.mock('./Standards/NftStandards/ERC1155/ERC1155Standard'); -type UnrestrictedMessenger = ControllerMessenger< +type UnrestrictedMessenger = Messenger< ExtractAvailableAction, ExtractAvailableEvent | ApprovalStateChange >; @@ -1457,7 +1457,9 @@ describe('TokensController', () => { }), }, }, - async ({ controller }) => { + async ({ controller, changeNetwork }) => { + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.goerli }); + const dummyTokens: Token[] = [ { address: '0x01', @@ -2901,7 +2903,7 @@ async function withController( fn, ] = args.length === 2 ? args : [{}, args[0]]; - const messenger = new ControllerMessenger(); + const messenger = new Messenger(); const approvalControllerMessenger = messenger.getRestricted({ name: 'ApprovalController', @@ -2914,7 +2916,7 @@ async function withController( typesExcludedFromRateLimiting: [ApprovalType.WatchAsset], }); - const controllerMessenger = messenger.getRestricted({ + const restrictedMessenger = messenger.getRestricted({ name: 'TokensController', allowedActions: [ 'ApprovalController:addRequest', @@ -2953,7 +2955,7 @@ async function withController( // where the provider can possibly be `undefined` if `networkClientId` is // not specified. provider: new FakeProvider(), - messenger: controllerMessenger, + messenger: restrictedMessenger, ...options, }); diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index bd3e3c4363b..67e23dc7f6d 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -7,7 +7,7 @@ import type { } from '@metamask/accounts-controller'; import type { AddApprovalRequest } from '@metamask/approval-controller'; import type { - RestrictedControllerMessenger, + RestrictedMessenger, ControllerGetStateAction, ControllerStateChangeEvent, } from '@metamask/base-controller'; @@ -160,7 +160,7 @@ export type AllowedEvents = /** * The messenger of the {@link TokensController}. */ -export type TokensControllerMessenger = RestrictedControllerMessenger< +export type TokensControllerMessenger = RestrictedMessenger< typeof controllerName, TokensControllerActions | AllowedActions, TokensControllerEvents | AllowedEvents, @@ -203,7 +203,7 @@ export class TokensController extends BaseController< * @param options.chainId - The chain ID of the current network. * @param options.provider - Network provider. * @param options.state - Initial state to set on this controller. - * @param options.messenger - The controller messenger. + * @param options.messenger - The messenger. */ constructor({ chainId: initialChainId, @@ -496,7 +496,7 @@ export class TokensController extends BaseController< const { allTokens, ignoredTokens, allDetectedTokens } = this.state; const importedTokensMap: { [key: string]: true } = {}; - let interactingChainId; + let interactingChainId: Hex = this.#chainId; if (networkClientId) { interactingChainId = this.messagingSystem.call( 'NetworkController:getNetworkClientById', @@ -506,14 +506,16 @@ export class TokensController extends BaseController< // Used later to dedupe imported tokens const newTokensMap = [ - ...(allTokens[interactingChainId ?? this.#chainId]?.[ - this.#getSelectedAccount().address - ] || []), + ...(allTokens[interactingChainId]?.[this.#getSelectedAccount().address] || + []), ...tokensToImport, - ].reduce((output, token) => { - output[token.address] = token; - return output; - }, {} as { [address: string]: Token }); + ].reduce( + (output, token) => { + output[token.address] = token; + return output; + }, + {} as { [address: string]: Token }, + ); try { tokensToImport.forEach((tokenToAdd) => { const { address, symbol, decimals, image, aggregators, name } = @@ -554,11 +556,13 @@ export class TokensController extends BaseController< }); this.update((state) => { - state.tokens = newTokens; + if (interactingChainId === this.#chainId) { + state.tokens = newTokens; + state.detectedTokens = newDetectedTokens; + state.ignoredTokens = newIgnoredTokens; + } state.allTokens = newAllTokens; - state.detectedTokens = newDetectedTokens; state.allDetectedTokens = newAllDetectedTokens; - state.ignoredTokens = newIgnoredTokens; state.allIgnoredTokens = newAllIgnoredTokens; }); } finally { diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 410054b59e9..97518b56ee1 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -148,21 +148,40 @@ export type { RatesControllerPollingStartedEvent, RatesControllerPollingStoppedEvent, } from './RatesController'; -export { - BalancesTracker, - MultichainBalancesController, - // constants - BALANCE_UPDATE_INTERVALS, - NETWORK_ASSETS_MAP, - MultichainNetworks, - MultichainNativeAssets, -} from './MultichainBalancesController'; +export { MultichainBalancesController } from './MultichainBalancesController'; export type { MultichainBalancesControllerState, MultichainBalancesControllerGetStateAction, - MultichainBalancesControllerUpdateBalancesAction, MultichainBalancesControllerStateChange, MultichainBalancesControllerActions, MultichainBalancesControllerEvents, MultichainBalancesControllerMessenger, } from './MultichainBalancesController'; + +export { + MultichainAssetsController, + getDefaultMultichainAssetsControllerState, +} from './MultichainAssetsController'; + +export type { + MultichainAssetsControllerState, + MultichainAssetsControllerGetStateAction, + MultichainAssetsControllerStateChangeEvent, + MultichainAssetsControllerActions, + MultichainAssetsControllerEvents, + MultichainAssetsControllerMessenger, +} from './MultichainAssetsController'; + +export { + MultiChainAssetsRatesController, + getDefaultMultichainAssetsRatesControllerState, +} from './MultichainAssetsRatesController'; + +export type { + MultichainAssetsRatesControllerState, + MultichainAssetsRatesControllerActions, + MultichainAssetsRatesControllerEvents, + MultichainAssetsRatesControllerGetStateAction, + MultichainAssetsRatesControllerStateChange, + MultichainAssetsRatesControllerMessenger, +} from './MultichainAssetsRatesController'; 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 a3cd09a0586..7e705a33ab6 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,3 +1,4 @@ +import type { ServicePolicy } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; /** @@ -53,7 +54,7 @@ export type AbstractTokenPricesService< ChainId extends Hex = Hex, TokenAddress extends Hex = Hex, Currency extends string = string, -> = { +> = Partial> & { /** * Retrieves prices in the given currency for the tokens identified by the * given addresses which are expected to live on the given chain. 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 e1efe858ec2..8f644e7e02e 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 @@ -14,6 +14,232 @@ import { const defaultMaxRetryDelay = 30_000; describe('CodefiTokenPricesServiceV2', () => { + describe('onBreak', () => { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers({ now: Date.now() }); + }); + + afterEach(() => { + clock.restore(); + }); + + it('registers a listener that is called upon break', async () => { + const retries = 3; + // Max consencutive failures is set to match number of calls in three update attempts (including retries) + const maximumConsecutiveFailures = (1 + retries) * 3; + // Initial interceptor for failing requests + nock('https://price.api.cx.metamask.io') + .get('/v2/chains/1/spot-prices') + .query({ + tokenAddresses: + '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .times(maximumConsecutiveFailures) + .replyWithError('Failed to fetch'); + // This interceptor should not be used + nock('https://price.api.cx.metamask.io') + .get('/v2/chains/1/spot-prices') + .query({ + tokenAddresses: + '0x0000000000000000000000000000000000000000,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': { + price: 148.17205755299946, + 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': { + 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, + }, + '0xccc': { + price: 148.1344197578456, + 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 onBreakHandler = jest.fn(); + const service = new CodefiTokenPricesServiceV2({ + retries, + maximumConsecutiveFailures, + // Ensure break duration is well over the max delay for a single request, so that the + // break doesn't end during a retry attempt + circuitBreakDuration: defaultMaxRetryDelay * 10, + }); + service.onBreak(onBreakHandler); + const fetchTokenPrices = () => + service.fetchTokenPrices({ + chainId: '0x1', + tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + currency: 'ETH', + }); + expect(onBreakHandler).not.toHaveBeenCalled(); + + // Initial three calls to exhaust maximum allowed failures + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _retryAttempt of Array(retries).keys()) { + // eslint-disable-next-line no-loop-func + await expect(() => + fetchTokenPricesWithFakeTimers({ + clock, + fetchTokenPrices, + retries, + }), + ).rejects.toThrow('Failed to fetch'); + } + + expect(onBreakHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('onDegraded', () => { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers({ now: Date.now() }); + }); + + afterEach(() => { + clock.restore(); + }); + + it('calls onDegraded when request is slower than threshold', async () => { + const degradedThreshold = 1000; + const retries = 0; + nock('https://price.api.cx.metamask.io') + .get('/v2/chains/1/spot-prices') + .query({ + tokenAddresses: + '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .delay(degradedThreshold * 2) + .reply(200, { + '0x0000000000000000000000000000000000000000': { + price: 14, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + }, + '0xaaa': { + price: 148.17205755299946, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + }, + '0xbbb': { + price: 33689.98134554716, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + }, + '0xccc': { + price: 148.1344197578456, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + }, + }); + const onDegradedHandler = jest.fn(); + const service = new CodefiTokenPricesServiceV2({ + degradedThreshold, + retries, + }); + service.onDegraded(onDegradedHandler); + + await fetchTokenPricesWithFakeTimers({ + clock, + fetchTokenPrices: () => + service.fetchTokenPrices({ + chainId: '0x1', + tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + currency: 'ETH', + }), + retries, + }); + + expect(onDegradedHandler).toHaveBeenCalledTimes(1); + }); + }); + describe('fetchTokenPrices', () => { 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') @@ -829,8 +1055,9 @@ describe('CodefiTokenPricesServiceV2', () => { clock.restore(); }); - it('does not call onDegraded when requests succeeds faster than threshold', async () => { + it('calls onDegraded when request is slower than threshold', async () => { const degradedThreshold = 1000; + const retries = 0; nock('https://price.api.cx.metamask.io') .get('/v2/chains/1/spot-prices') .query({ @@ -839,326 +1066,38 @@ describe('CodefiTokenPricesServiceV2', () => { vsCurrency: 'ETH', includeMarketData: 'true', }) - .delay(degradedThreshold / 2) + .delay(degradedThreshold * 2) .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': { price: 148.17205755299946, 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': { 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, }, '0xccc': { price: 148.1344197578456, 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 onDegradedHandler = jest.fn(); const service = new CodefiTokenPricesServiceV2({ degradedThreshold, onDegraded: onDegradedHandler, - }); - - await service.fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], - currency: 'ETH', - }); - - expect(onDegradedHandler).not.toHaveBeenCalled(); - }); - - it('does not call onDegraded when requests succeeds on retry faster than threshold', async () => { - // Set threshold above max retry delay to ensure the time is always under the threshold, - // even with random jitter - const degradedThreshold = defaultMaxRetryDelay + 1000; - const retries = 1; - // Initial interceptor for failing request - nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') - .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', - vsCurrency: 'ETH', - includeMarketData: 'true', - }) - .replyWithError('Failed to fetch'); - // Second interceptor for successful response - nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') - .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', - vsCurrency: 'ETH', - includeMarketData: 'true', - }) - .delay(500) - .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - }, - '0xaaa': { - price: 148.17205755299946, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - }, - '0xbbb': { - price: 33689.98134554716, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - }, - '0xccc': { - price: 148.1344197578456, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - }, - }); - const onDegradedHandler = jest.fn(); - const service = new CodefiTokenPricesServiceV2({ - degradedThreshold, - onDegraded: onDegradedHandler, - retries, - }); - - await fetchTokenPricesWithFakeTimers({ - clock, - fetchTokenPrices: () => - service.fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], - currency: 'ETH', - }), - retries, - }); - - expect(onDegradedHandler).not.toHaveBeenCalled(); - }); - - it('calls onDegraded when request fails', async () => { - const retries = 0; - nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') - .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', - vsCurrency: 'ETH', - includeMarketData: 'true', - }) - .replyWithError('Failed to fetch'); - const onDegradedHandler = jest.fn(); - const service = new CodefiTokenPricesServiceV2({ - onDegraded: onDegradedHandler, - retries, - }); - - await expect(() => - fetchTokenPricesWithFakeTimers({ - clock, - fetchTokenPrices: () => - service.fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], - currency: 'ETH', - }), - retries, - }), - ).rejects.toThrow('Failed to fetch'); - - expect(onDegradedHandler).toHaveBeenCalledTimes(1); - }); - - it('calls onDegraded when request is slower than threshold', async () => { - const degradedThreshold = 1000; - const retries = 0; - nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') - .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', - vsCurrency: 'ETH', - includeMarketData: 'true', - }) - .delay(degradedThreshold * 2) - .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - }, - '0xaaa': { - price: 148.17205755299946, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - }, - '0xbbb': { - price: 33689.98134554716, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - }, - '0xccc': { - price: 148.1344197578456, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - }, - }); - const onDegradedHandler = jest.fn(); - const service = new CodefiTokenPricesServiceV2({ - degradedThreshold, - onDegraded: onDegradedHandler, - retries, - }); - - await fetchTokenPricesWithFakeTimers({ - clock, - fetchTokenPrices: () => - service.fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], - currency: 'ETH', - }), - retries, - }); - - expect(onDegradedHandler).toHaveBeenCalledTimes(1); - }); - - it('calls onDegraded when request is slower than threshold after retry', async () => { - const degradedThreshold = 1000; - const retries = 1; - // Initial interceptor for failing request - nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') - .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', - vsCurrency: 'ETH', - includeMarketData: 'true', - }) - .replyWithError('Failed to fetch'); - // Second interceptor for successful response - nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') - .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', - vsCurrency: 'ETH', - includeMarketData: 'true', - }) - .delay(degradedThreshold * 2) - .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - }, - '0xaaa': { - price: 148.17205755299946, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - }, - '0xbbb': { - price: 33689.98134554716, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - }, - '0xccc': { - price: 148.1344197578456, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - }, - }); - const onDegradedHandler = jest.fn(); - const service = new CodefiTokenPricesServiceV2({ - degradedThreshold, - onDegraded: onDegradedHandler, - retries, + retries, }); await fetchTokenPricesWithFakeTimers({ @@ -1187,7 +1126,7 @@ describe('CodefiTokenPricesServiceV2', () => { clock.restore(); }); - it('stops making fetch requests after too many consecutive failures', async () => { + it('calls onBreak handler upon break', async () => { const retries = 3; // Max consencutive failures is set to match number of calls in three update attempts (including retries) const maximumConsecutiveFailures = (1 + retries) * 3; @@ -1203,7 +1142,7 @@ describe('CodefiTokenPricesServiceV2', () => { .times(maximumConsecutiveFailures) .replyWithError('Failed to fetch'); // This interceptor should not be used - const successfullCallScope = nock('https://price.api.cx.metamask.io') + nock('https://price.api.cx.metamask.io') .get('/v2/chains/1/spot-prices') .query({ tokenAddresses: @@ -1297,9 +1236,11 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange1y: -2.2992517267242754, }, }); + const onBreakHandler = jest.fn(); const service = new CodefiTokenPricesServiceV2({ retries, maximumConsecutiveFailures, + onBreak: onBreakHandler, // Ensure break duration is well over the max delay for a single request, so that the // break doesn't end during a retry attempt circuitBreakDuration: defaultMaxRetryDelay * 10, @@ -1310,6 +1251,8 @@ describe('CodefiTokenPricesServiceV2', () => { tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], currency: 'ETH', }); + expect(onBreakHandler).not.toHaveBeenCalled(); + // Initial three calls to exhaust maximum allowed failures // eslint-disable-next-line @typescript-eslint/no-unused-vars for (const _retryAttempt of Array(retries).keys()) { @@ -1323,642 +1266,7 @@ describe('CodefiTokenPricesServiceV2', () => { ).rejects.toThrow('Failed to fetch'); } - await expect(() => - fetchTokenPricesWithFakeTimers({ - clock, - fetchTokenPrices, - retries, - }), - ).rejects.toThrow( - 'Execution prevented because the circuit breaker is open', - ); - expect(successfullCallScope.isDone()).toBe(false); - }); - - it('calls onBreak handler upon break', async () => { - const retries = 3; - // Max consencutive failures is set to match number of calls in three update attempts (including retries) - const maximumConsecutiveFailures = (1 + retries) * 3; - // Initial interceptor for failing requests - nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') - .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', - vsCurrency: 'ETH', - includeMarketData: 'true', - }) - .times(maximumConsecutiveFailures) - .replyWithError('Failed to fetch'); - // This interceptor should not be used - nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') - .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,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': { - price: 148.17205755299946, - 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': { - 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, - }, - '0xccc': { - price: 148.1344197578456, - 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 onBreakHandler = jest.fn(); - const service = new CodefiTokenPricesServiceV2({ - retries, - maximumConsecutiveFailures, - // Ensure break duration is well over the max delay for a single request, so that the - // break doesn't end during a retry attempt - onBreak: onBreakHandler, - circuitBreakDuration: defaultMaxRetryDelay * 10, - }); - const fetchTokenPrices = () => - service.fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], - currency: 'ETH', - }); - expect(onBreakHandler).not.toHaveBeenCalled(); - - // Initial three calls to exhaust maximum allowed failures - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const _retryAttempt of Array(retries).keys()) { - // eslint-disable-next-line no-loop-func - await expect(() => - fetchTokenPricesWithFakeTimers({ - clock, - fetchTokenPrices, - retries, - }), - ).rejects.toThrow('Failed to fetch'); - } - - expect(onBreakHandler).toHaveBeenCalledTimes(1); - }); - - it('stops calling onDegraded after circuit break', async () => { - const retries = 3; - // Max consencutive failures is set to match number of calls in three update attempts (including retries) - const maximumConsecutiveFailures = (1 + retries) * 3; - // Initial interceptor for failing requests - nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') - .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', - vsCurrency: 'ETH', - includeMarketData: 'true', - }) - .times(maximumConsecutiveFailures) - .replyWithError('Failed to fetch'); - const onBreakHandler = jest.fn(); - const onDegradedHandler = jest.fn(); - const service = new CodefiTokenPricesServiceV2({ - retries, - maximumConsecutiveFailures, - // Ensure break duration is well over the max delay for a single request, so that the - // break doesn't end during a retry attempt - onBreak: onBreakHandler, - onDegraded: onDegradedHandler, - circuitBreakDuration: defaultMaxRetryDelay * 10, - }); - const fetchTokenPrices = () => - service.fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], - currency: 'ETH', - }); - expect(onBreakHandler).not.toHaveBeenCalled(); - // Initial three calls to exhaust maximum allowed failures - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const _retryAttempt of Array(retries).keys()) { - // eslint-disable-next-line no-loop-func - await expect(() => - fetchTokenPricesWithFakeTimers({ - clock, - fetchTokenPrices, - retries, - }), - ).rejects.toThrow('Failed to fetch'); - } - // Confirm that circuit is broken expect(onBreakHandler).toHaveBeenCalledTimes(1); - // Should be called twice by now, once per update attempt prior to break - expect(onDegradedHandler).toHaveBeenCalledTimes(2); - - await expect(() => - fetchTokenPricesWithFakeTimers({ - clock, - fetchTokenPrices, - retries, - }), - ).rejects.toThrow( - 'Execution prevented because the circuit breaker is open', - ); - - expect(onDegradedHandler).toHaveBeenCalledTimes(2); - }); - - it('keeps circuit closed if first request fails when half-open', async () => { - const retries = 3; - // Max consencutive failures is set to match number of calls in three update attempts (including retries) - const maximumConsecutiveFailures = (1 + retries) * 3; - // Ensure break duration is well over the max delay for a single request, so that the - // break doesn't end during a retry attempt - const circuitBreakDuration = defaultMaxRetryDelay * 10; - // Initial interceptor for failing requests - nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') - .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', - vsCurrency: 'ETH', - includeMarketData: 'true', - }) - // The +1 is for the additional request when the circuit is half-open - .times(maximumConsecutiveFailures + 1) - .replyWithError('Failed to fetch'); - // This interceptor should not be used - const successfullCallScope = nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') - .query({ - tokenAddresses: '0xAAA,0xBBB,0xCCC', - vsCurrency: 'ETH', - }) - .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': { - price: 148.17205755299946, - 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': { - 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, - }, - '0xccc': { - price: 148.1344197578456, - 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 service = new CodefiTokenPricesServiceV2({ - retries, - maximumConsecutiveFailures, - circuitBreakDuration, - }); - const fetchTokenPrices = () => - service.fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], - currency: 'ETH', - }); - // Initial three calls to exhaust maximum allowed failures - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const _retryAttempt of Array(retries).keys()) { - // eslint-disable-next-line no-loop-func - await expect(() => - fetchTokenPricesWithFakeTimers({ - clock, - fetchTokenPrices, - retries, - }), - ).rejects.toThrow('Failed to fetch'); - } - // Confirm that circuit has broken - await expect(() => - fetchTokenPricesWithFakeTimers({ - clock, - fetchTokenPrices, - retries, - }), - ).rejects.toThrow( - 'Execution prevented because the circuit breaker is open', - ); - // Wait for circuit to move to half-open - await clock.tickAsync(circuitBreakDuration); - - // The circuit should remain open after the first request fails - // The fetch error is replaced by the circuit break error due to the retries - await expect(() => - fetchTokenPricesWithFakeTimers({ - clock, - fetchTokenPrices, - retries, - }), - ).rejects.toThrow( - 'Execution prevented because the circuit breaker is open', - ); - - // Confirm that the circuit is still open - await expect(() => - fetchTokenPricesWithFakeTimers({ - clock, - fetchTokenPrices, - retries, - }), - ).rejects.toThrow( - 'Execution prevented because the circuit breaker is open', - ); - expect(successfullCallScope.isDone()).toBe(false); - }); - - it('recovers after circuit break', async () => { - const retries = 3; - // Max consencutive failures is set to match number of calls in three update attempts (including retries) - const maximumConsecutiveFailures = (1 + retries) * 3; - // Ensure break duration is well over the max delay for a single request, so that the - // break doesn't end during a retry attempt - const circuitBreakDuration = defaultMaxRetryDelay * 10; - // Initial interceptor for failing requests - nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') - .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', - vsCurrency: 'ETH', - includeMarketData: 'true', - }) - .times(maximumConsecutiveFailures) - .replyWithError('Failed to fetch'); - // Later interceptor for successfull request after recovery - nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') - .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,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': { - price: 148.17205755299946, - 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': { - 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, - }, - '0xccc': { - price: 148.1344197578456, - 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 service = new CodefiTokenPricesServiceV2({ - retries, - maximumConsecutiveFailures, - circuitBreakDuration, - }); - const fetchTokenPrices = () => - service.fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], - currency: 'ETH', - }); - // Initial three calls to exhaust maximum allowed failures - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const _retryAttempt of Array(retries).keys()) { - // eslint-disable-next-line no-loop-func - await expect(() => - fetchTokenPricesWithFakeTimers({ - clock, - fetchTokenPrices, - retries, - }), - ).rejects.toThrow('Failed to fetch'); - } - // Confirm that circuit has broken - await expect(() => - fetchTokenPricesWithFakeTimers({ - clock, - fetchTokenPrices, - retries, - }), - ).rejects.toThrow( - 'Execution prevented because the circuit breaker is open', - ); - // Wait for circuit to move to half-open - await clock.tickAsync(circuitBreakDuration); - - const marketDataTokensByAddress = await fetchTokenPricesWithFakeTimers({ - clock, - fetchTokenPrices, - retries, - }); - - 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': { - tokenAddress: '0xAAA', - 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: 148.17205755299946, - 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': { - tokenAddress: '0xBBB', - 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: 33689.98134554716, - 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, - }, - '0xCCC': { - tokenAddress: '0xCCC', - 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: 148.1344197578456, - 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, - }, - }); }); }); }); 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 4f163203e4d..901d18f7245 100644 --- a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts @@ -1,16 +1,14 @@ -import { handleFetch } from '@metamask/controller-utils'; +import { + createServicePolicy, + DEFAULT_CIRCUIT_BREAK_DURATION, + DEFAULT_DEGRADED_THRESHOLD, + DEFAULT_MAX_CONSECUTIVE_FAILURES, + DEFAULT_MAX_RETRIES, + handleFetch, +} from '@metamask/controller-utils'; +import type { ServicePolicy } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; import { hexToNumber } from '@metamask/utils'; -import { - circuitBreaker, - ConsecutiveBreaker, - ExponentialBackoff, - handleAll, - type IPolicy, - retry, - wrap, - CircuitState, -} from 'cockatiel'; import type { AbstractTokenPricesService, @@ -271,13 +269,6 @@ type SupportedChainId = (typeof SUPPORTED_CHAIN_IDS)[number]; */ const BASE_URL = 'https://price.api.cx.metamask.io/v2'; -const DEFAULT_TOKEN_PRICE_RETRIES = 3; -// Each update attempt will result (1 + retries) calls if the server is down -const DEFAULT_TOKEN_PRICE_MAX_CONSECUTIVE_FAILURES = - (1 + DEFAULT_TOKEN_PRICE_RETRIES) * 3; - -const DEFAULT_DEGRADED_THRESHOLD = 5_000; - /** * The shape of the data that the /spot-prices endpoint returns. */ @@ -365,31 +356,64 @@ export class CodefiTokenPricesServiceV2 implements AbstractTokenPricesService { - #tokenPricePolicy: IPolicy; + readonly #policy: ServicePolicy; /** * Construct a Codefi Token Price Service. * - * @param options - Constructor options - * @param options.degradedThreshold - The threshold between "normal" and "degrated" service, - * in milliseconds. - * @param options.retries - Number of retry attempts for each token price update. - * @param options.maximumConsecutiveFailures - The maximum number of consecutive failures - * allowed before breaking the circuit and pausing further updates. - * @param options.onBreak - An event handler for when the circuit breaks, useful for capturing - * metrics about network failures. - * @param options.onDegraded - An event handler for when the circuit remains closed, but requests - * are failing or resolving too slowly (i.e. resolving more slowly than the `degradedThreshold). - * @param options.circuitBreakDuration - The amount of time to wait when the circuit breaks - * from too many consecutive failures. + * @param args - The arguments. + * @param args.degradedThreshold - The length of time (in milliseconds) + * that governs when the service is regarded as degraded (affecting when + * `onDegraded` is called). Defaults to 5 seconds. + * @param args.retries - Number of retry attempts for each fetch request. + * @param args.maximumConsecutiveFailures - The maximum number of consecutive + * failures allowed before breaking the circuit and pausing further updates. + * @param args.circuitBreakDuration - The amount of time to wait when the + * circuit breaks from too many consecutive failures. */ + constructor(args?: { + degradedThreshold?: number; + retries?: number; + maximumConsecutiveFailures?: number; + circuitBreakDuration?: number; + }); + + /** + * Construct a Codefi Token Price Service. + * + * @deprecated This signature is deprecated; please use the `onBreak` and + * `onDegraded` methods instead. + * @param args - The arguments. + * @param args.degradedThreshold - The length of time (in milliseconds) + * that governs when the service is regarded as degraded (affecting when + * `onDegraded` is called). Defaults to 5 seconds. + * @param args.retries - Number of retry attempts for each fetch request. + * @param args.maximumConsecutiveFailures - The maximum number of consecutive + * failures allowed before breaking the circuit and pausing further updates. + * @param args.onBreak - Callback for when the circuit breaks, useful + * for capturing metrics about network failures. + * @param args.onDegraded - Callback for when the API responds successfully + * but takes too long to respond (5 seconds or more). + * @param args.circuitBreakDuration - The amount of time to wait when the + * circuit breaks from too many consecutive failures. + */ + // eslint-disable-next-line @typescript-eslint/unified-signatures + constructor(args?: { + degradedThreshold?: number; + retries?: number; + maximumConsecutiveFailures?: number; + onBreak?: () => void; + onDegraded?: () => void; + circuitBreakDuration?: number; + }); + constructor({ degradedThreshold = DEFAULT_DEGRADED_THRESHOLD, - retries = DEFAULT_TOKEN_PRICE_RETRIES, - maximumConsecutiveFailures = DEFAULT_TOKEN_PRICE_MAX_CONSECUTIVE_FAILURES, + retries = DEFAULT_MAX_RETRIES, + maximumConsecutiveFailures = DEFAULT_MAX_CONSECUTIVE_FAILURES, onBreak, onDegraded, - circuitBreakDuration = 30 * 60 * 1000, + circuitBreakDuration = DEFAULT_CIRCUIT_BREAK_DURATION, }: { degradedThreshold?: number; retries?: number; @@ -398,35 +422,40 @@ export class CodefiTokenPricesServiceV2 onDegraded?: () => void; circuitBreakDuration?: number; } = {}) { - // Construct a policy that will retry each update, and halt further updates - // for a certain period after too many consecutive failures. - const retryPolicy = retry(handleAll, { - maxAttempts: retries, - backoff: new ExponentialBackoff(), - }); - const circuitBreakerPolicy = circuitBreaker(handleAll, { - halfOpenAfter: circuitBreakDuration, - breaker: new ConsecutiveBreaker(maximumConsecutiveFailures), + this.#policy = createServicePolicy({ + maxRetries: retries, + maxConsecutiveFailures: maximumConsecutiveFailures, + circuitBreakDuration, + degradedThreshold, }); if (onBreak) { - circuitBreakerPolicy.onBreak(onBreak); + this.#policy.onBreak(onBreak); } if (onDegraded) { - retryPolicy.onGiveUp(() => { - if (circuitBreakerPolicy.state === CircuitState.Closed) { - onDegraded(); - } - }); - retryPolicy.onSuccess(({ duration }) => { - if ( - circuitBreakerPolicy.state === CircuitState.Closed && - duration > degradedThreshold - ) { - onDegraded(); - } - }); + this.#policy.onDegraded(onDegraded); } - this.#tokenPricePolicy = wrap(retryPolicy, circuitBreakerPolicy); + } + + /** + * Listens for when the request to the API fails too many times in a row. + * + * @param args - The same arguments that {@link ServicePolicy.onBreak} + * takes. + * @returns What {@link ServicePolicy.onBreak} returns. + */ + onBreak(...args: Parameters) { + return this.#policy.onBreak(...args); + } + + /** + * Listens for when the API is degraded. + * + * @param args - The same arguments that {@link ServicePolicy.onDegraded} + * takes. + * @returns What {@link ServicePolicy.onDegraded} returns. + */ + onDegraded(...args: Parameters) { + return this.#policy.onDegraded(...args); } /** @@ -459,7 +488,7 @@ export class CodefiTokenPricesServiceV2 url.searchParams.append('includeMarketData', 'true'); const addressCryptoDataMap: MarketDataByTokenAddress = - await this.#tokenPricePolicy.execute(() => + await this.#policy.execute(() => handleFetch(url, { headers: { 'Cache-Control': 'no-cache' } }), ); diff --git a/packages/assets-controllers/tsconfig.build.json b/packages/assets-controllers/tsconfig.build.json index 5d38b996867..5e74c070fc5 100644 --- a/packages/assets-controllers/tsconfig.build.json +++ b/packages/assets-controllers/tsconfig.build.json @@ -13,7 +13,8 @@ { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../network-controller/tsconfig.build.json" }, { "path": "../preferences-controller/tsconfig.build.json" }, - { "path": "../polling-controller/tsconfig.build.json" } + { "path": "../polling-controller/tsconfig.build.json" }, + { "path": "../permission-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/assets-controllers/tsconfig.json b/packages/assets-controllers/tsconfig.json index 05bd347469b..d86b6d1a374 100644 --- a/packages/assets-controllers/tsconfig.json +++ b/packages/assets-controllers/tsconfig.json @@ -12,7 +12,8 @@ { "path": "../keyring-controller" }, { "path": "../network-controller" }, { "path": "../preferences-controller" }, - { "path": "../polling-controller" } + { "path": "../polling-controller" }, + { "path": "../permission-controller" } ], "include": ["../../types", "./src", "../../tests"] } diff --git a/packages/base-controller/CHANGELOG.md b/packages/base-controller/CHANGELOG.md index 7e6a8261960..922a2ea1ef4 100644 --- a/packages/base-controller/CHANGELOG.md +++ b/packages/base-controller/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [8.0.0] + +### Changed + +- **BREAKING:** Remove deprecated messenger-related exports and simplify `RestrictedMessenger` constructor ([#5260](https://github.com/MetaMask/core/pull/5260)) + - Remove `ControllerMessenger` export which was an alias for `Messenger`. Consumers should import `Messenger` directly + - Remove `RestrictedControllerMessenger` export which was an alias for `RestrictedMessenger`. Consumers should import `RestrictedMessenger` directly + - Remove `RestrictedControllerMessengerConstraint` type export which was an alias for `RestrictedMessengerConstraint`. Consumers should use `RestrictedMessengerConstraint` type directly + - Simplify `RestrictedMessenger` constructor by removing deprecated `controllerMessenger` parameter. The messenger instance should now be passed using only the `messenger` parameter instead of supporting both options +- Widen input parameter for type guard `isBaseController` from `ControllerInstance` to `unknown` ([#5018](https://github.com/MetaMask/core/pull/5018/)) +- Bump `@metamask/json-rpc-engine` from `^10.0.2` to `^10.0.3` ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + +### Removed + +- **BREAKING:** Remove class `BaseControllerV1` and type guard `isBaseControllerV1` ([#5018](https://github.com/MetaMask/core/pull/5018/)) +- **BREAKING:** Remove types `BaseConfig`, `BaseControllerV1Instance`, `BaseState`, `ConfigConstraintV1`, `Listener`, `StateConstraintV1`, `LegacyControllerStateConstraint`, `ControllerInstance` ([#5018](https://github.com/MetaMask/core/pull/5018/)) + ## [7.1.1] ### Changed @@ -286,7 +304,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/base-controller@7.1.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/base-controller@8.0.0...HEAD +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/base-controller@7.1.1...@metamask/base-controller@8.0.0 [7.1.1]: https://github.com/MetaMask/core/compare/@metamask/base-controller@7.1.0...@metamask/base-controller@7.1.1 [7.1.0]: https://github.com/MetaMask/core/compare/@metamask/base-controller@7.0.2...@metamask/base-controller@7.1.0 [7.0.2]: https://github.com/MetaMask/core/compare/@metamask/base-controller@7.0.1...@metamask/base-controller@7.0.2 diff --git a/packages/base-controller/package.json b/packages/base-controller/package.json index f1134abc881..f3eb0e90e38 100644 --- a/packages/base-controller/package.json +++ b/packages/base-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/base-controller", - "version": "7.1.1", + "version": "8.0.0", "description": "Provides scaffolding for controllers as well a communication system for all controllers", "keywords": [ "MetaMask", @@ -46,12 +46,12 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/utils": "^11.0.1", + "@metamask/utils": "^11.1.0", "immer": "^9.0.6" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/json-rpc-engine": "^10.0.2", + "@metamask/json-rpc-engine": "^10.0.3", "@types/jest": "^27.4.1", "@types/sinon": "^9.0.10", "deepmerge": "^4.2.2", diff --git a/packages/base-controller/src/BaseControllerV1.test.ts b/packages/base-controller/src/BaseControllerV1.test.ts deleted file mode 100644 index 57a71b589f4..00000000000 --- a/packages/base-controller/src/BaseControllerV1.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; -import * as sinon from 'sinon'; - -import type { BaseConfig, BaseState } from './BaseControllerV1'; -import { - BaseControllerV1 as BaseController, - isBaseControllerV1, -} from './BaseControllerV1'; -import type { - CountControllerAction, - CountControllerEvent, -} from './BaseControllerV2.test'; -import { - CountController, - countControllerName, - countControllerStateMetadata, - getCountMessenger, -} from './BaseControllerV2.test'; -import { ControllerMessenger } from './Messenger'; - -const STATE = { name: 'foo' }; -const CONFIG = { disabled: true }; - -// eslint-disable-next-line jest/no-export -export class TestController extends BaseController { - constructor(config?: BaseConfig, state?: BaseState) { - super(config, state); - this.initialize(); - } -} - -describe('isBaseControllerV1', () => { - it('should return false if passed a V1 controller', () => { - const controller = new TestController(); - expect(isBaseControllerV1(controller)).toBe(true); - }); - - it('should return false if passed a V2 controller', () => { - const controllerMessenger = new ControllerMessenger< - CountControllerAction, - CountControllerEvent - >(); - const controller = new CountController({ - messenger: getCountMessenger(controllerMessenger), - name: countControllerName, - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - expect(isBaseControllerV1(controller)).toBe(false); - }); - - it('should return false if passed a non-controller', () => { - const notController = new JsonRpcEngine(); - // @ts-expect-error Intentionally passing invalid input to test runtime behavior - expect(isBaseControllerV1(notController)).toBe(false); - }); -}); - -describe('BaseController', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should set initial state', () => { - const controller = new TestController(undefined, STATE); - expect(controller.state).toStrictEqual(STATE); - }); - - it('should set initial config', () => { - const controller = new TestController(CONFIG); - expect(controller.config).toStrictEqual(CONFIG); - }); - - it('should overwrite state', () => { - const controller = new TestController(); - expect(controller.state).toStrictEqual({}); - controller.update(STATE, true); - expect(controller.state).toStrictEqual(STATE); - }); - - it('should overwrite config', () => { - const controller = new TestController(); - expect(controller.config).toStrictEqual({}); - controller.configure(CONFIG, true); - expect(controller.config).toStrictEqual(CONFIG); - }); - - it('should be able to partially update the config', () => { - const controller = new TestController(CONFIG); - expect(controller.config).toStrictEqual(CONFIG); - controller.configure({ disabled: false }, false, false); - expect(controller.config).toStrictEqual({ disabled: false }); - }); - - it('should notify all listeners', () => { - const controller = new TestController(undefined, STATE); - const listenerOne = sinon.stub(); - const listenerTwo = sinon.stub(); - controller.subscribe(listenerOne); - controller.subscribe(listenerTwo); - controller.notify(); - expect(listenerOne.calledOnce).toBe(true); - expect(listenerTwo.calledOnce).toBe(true); - expect(listenerOne.getCall(0).args[0]).toStrictEqual(STATE); - expect(listenerTwo.getCall(0).args[0]).toStrictEqual(STATE); - }); - - it('should not notify unsubscribed listeners', () => { - const controller = new TestController(); - const listener = sinon.stub(); - controller.subscribe(listener); - controller.unsubscribe(listener); - controller.unsubscribe(() => null); - controller.notify(); - expect(listener.called).toBe(false); - }); -}); diff --git a/packages/base-controller/src/BaseControllerV1.ts b/packages/base-controller/src/BaseControllerV1.ts deleted file mode 100644 index 97843f642e7..00000000000 --- a/packages/base-controller/src/BaseControllerV1.ts +++ /dev/null @@ -1,251 +0,0 @@ -import type { PublicInterface } from '@metamask/utils'; - -import type { ControllerInstance } from './BaseControllerV2'; - -/** - * Determines if the given controller is an instance of `BaseControllerV1` - * - * @param controller - Controller instance to check - * @returns True if the controller is an instance of `BaseControllerV1` - */ -export function isBaseControllerV1( - controller: ControllerInstance, -): controller is BaseControllerV1Instance { - return ( - 'name' in controller && - typeof controller.name === 'string' && - 'config' in controller && - typeof controller.config === 'object' && - 'defaultConfig' in controller && - typeof controller.defaultConfig === 'object' && - 'state' in controller && - typeof controller.state === 'object' && - 'defaultState' in controller && - typeof controller.defaultState === 'object' && - 'disabled' in controller && - typeof controller.disabled === 'boolean' && - 'subscribe' in controller && - typeof controller.subscribe === 'function' - ); -} - -/** - * State change callbacks - */ -// TODO: Either fix this lint violation or explain why it's necessary to ignore. -// eslint-disable-next-line @typescript-eslint/naming-convention -export type Listener = (state: T) => void; - -/** - * @type BaseConfig - * - * Base controller configuration - * @property disabled - Determines if this controller is enabled - */ -// This interface was created before this ESLint rule was added. -// Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export interface BaseConfig { - disabled?: boolean; -} - -/** - * @type BaseState - * - * Base state representation - * @property name - Unique name for this controller - */ -// This interface was created before this ESLint rule was added. -// Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export interface BaseState { - name?: string; -} - -/** - * The narrowest supertype for `BaseControllerV1` config objects. - * This type can be assigned to any `BaseControllerV1` config object. - */ -export type ConfigConstraint = BaseConfig & object; - -/** - * The narrowest supertype for `BaseControllerV1` state objects. - * This type can be assigned to any `BaseControllerV1` state object. - */ -export type StateConstraint = BaseState & object; - -/** - * The widest subtype of all controller instances that extend from `BaseControllerV1`. - * Any `BaseControllerV1` instance can be assigned to this type. - */ -export type BaseControllerV1Instance = PublicInterface< - BaseControllerV1 ->; - -/** - * @deprecated This class has been renamed to BaseControllerV1 and is no longer recommended for use for controllers. Please use BaseController (formerly BaseControllerV2) instead. - * - * Controller class that provides configuration, state management, and subscriptions. - * - * The core purpose of every controller is to maintain an internal data object - * called "state". Each controller is responsible for its own state, and all global wallet state - * is tracked in a controller as state. - */ -// TODO: Either fix this lint violation or explain why it's necessary to ignore. -// eslint-disable-next-line @typescript-eslint/naming-convention -export class BaseControllerV1 { - /** - * Default options used to configure this controller - */ - defaultConfig: C = {} as never; - - /** - * Default state set on this controller - */ - defaultState: S = {} as never; - - /** - * Determines if listeners are notified of state changes - */ - disabled = false; - - /** - * Name of this controller used during composition - */ - name = 'BaseController'; - - private readonly initialConfig: Partial; - - private readonly initialState: Partial; - - private internalConfig: C = this.defaultConfig; - - private internalState: S = this.defaultState; - - private readonly internalListeners: Listener[] = []; - - /** - * Creates a BaseControllerV1 instance. Both initial state and initial - * configuration options are merged with defaults upon initialization. - * - * @param config - Initial options used to configure this controller. - * @param state - Initial state to set on this controller. - */ - constructor(config: Partial = {}, state: Partial = {}) { - this.initialState = state; - this.initialConfig = config; - } - - /** - * Enables the controller. This sets each config option as a member - * variable on this instance and triggers any defined setters. This - * also sets initial state and triggers any listeners. - * - * @returns This controller instance. - */ - protected initialize() { - this.internalState = this.defaultState; - this.internalConfig = this.defaultConfig; - this.configure(this.initialConfig); - this.update(this.initialState); - return this; - } - - /** - * Retrieves current controller configuration options. - * - * @returns The current configuration. - */ - get config() { - return this.internalConfig; - } - - /** - * Retrieves current controller state. - * - * @returns The current state. - */ - get state() { - return this.internalState; - } - - /** - * Updates controller configuration. - * - * @param config - New configuration options. - * @param overwrite - Overwrite config instead of merging. - * @param fullUpdate - Boolean that defines if the update is partial or not. - */ - configure(config: Partial, overwrite = false, fullUpdate = true) { - if (fullUpdate) { - this.internalConfig = overwrite - ? (config as C) - : Object.assign(this.internalConfig, config); - - for (const key of Object.keys(this.internalConfig) as (keyof C)[]) { - const value = this.internalConfig[key]; - if (value !== undefined) { - (this as unknown as C)[key] = value; - } - } - } else { - for (const key of Object.keys(config) as (keyof C)[]) { - /* istanbul ignore else */ - if (this.internalConfig[key] !== undefined) { - const value = (config as C)[key]; - this.internalConfig[key] = value; - (this as unknown as C)[key] = value; - } - } - } - } - - /** - * Notifies all subscribed listeners of current state. - */ - notify() { - if (this.disabled) { - return; - } - - this.internalListeners.forEach((listener) => { - listener(this.internalState); - }); - } - - /** - * Adds new listener to be notified of state changes. - * - * @param listener - The callback triggered when state changes. - */ - subscribe(listener: Listener) { - this.internalListeners.push(listener); - } - - /** - * Removes existing listener from receiving state changes. - * - * @param listener - The callback to remove. - * @returns `true` if a listener is found and unsubscribed. - */ - unsubscribe(listener: Listener) { - const index = this.internalListeners.findIndex((cb) => listener === cb); - index > -1 && this.internalListeners.splice(index, 1); - return index > -1; - } - - /** - * Updates controller state. - * - * @param state - The new state. - * @param overwrite - Overwrite state instead of merging. - */ - update(state: Partial, overwrite = false) { - this.internalState = overwrite - ? Object.assign({}, state as S) - : Object.assign({}, this.internalState, state); - this.notify(); - } -} - -export default BaseControllerV1; diff --git a/packages/base-controller/src/BaseControllerV2.test.ts b/packages/base-controller/src/BaseControllerV2.test.ts index fc9f06516f9..6fc0c633a6f 100644 --- a/packages/base-controller/src/BaseControllerV2.test.ts +++ b/packages/base-controller/src/BaseControllerV2.test.ts @@ -2,8 +2,6 @@ import type { Draft, Patch } from 'immer'; import * as sinon from 'sinon'; -import { JsonRpcEngine } from '../../json-rpc-engine/src'; -import { TestController } from './BaseControllerV1.test'; import type { ControllerGetStateAction, ControllerStateChangeEvent, @@ -14,8 +12,9 @@ import { getPersistentState, isBaseController, } from './BaseControllerV2'; -import { ControllerMessenger } from './Messenger'; -import type { RestrictedControllerMessenger } from './RestrictedMessenger'; +import { Messenger } from './Messenger'; +import type { RestrictedMessenger } from './RestrictedMessenger'; +import { JsonRpcEngine } from '../../json-rpc-engine/src'; export const countControllerName = 'CountController'; @@ -40,7 +39,7 @@ export const countControllerStateMetadata = { }, }; -type CountMessenger = RestrictedControllerMessenger< +type CountMessenger = RestrictedMessenger< typeof countControllerName, CountControllerAction, CountControllerEvent, @@ -49,24 +48,18 @@ type CountMessenger = RestrictedControllerMessenger< >; /** - * Constructs a restricted controller messenger for the Count controller. + * Constructs a restricted messenger for the Count controller. * - * @param controllerMessenger - The controller messenger. - * @returns A restricted controller messenger for the Count controller. + * @param messenger - The messenger. + * @returns A restricted messenger for the Count controller. */ export function getCountMessenger( - controllerMessenger?: ControllerMessenger< - CountControllerAction, - CountControllerEvent - >, + messenger?: Messenger, ): CountMessenger { - if (!controllerMessenger) { - controllerMessenger = new ControllerMessenger< - CountControllerAction, - CountControllerEvent - >(); + if (!messenger) { + messenger = new Messenger(); } - return controllerMessenger.getRestricted({ + return messenger.getRestricted({ name: countControllerName, allowedActions: [], allowedEvents: [], @@ -125,7 +118,7 @@ const messagesControllerStateMetadata = { }, }; -type MessagesMessenger = RestrictedControllerMessenger< +type MessagesMessenger = RestrictedMessenger< typeof messagesControllerName, MessagesControllerAction, MessagesControllerEvent, @@ -134,24 +127,21 @@ type MessagesMessenger = RestrictedControllerMessenger< >; /** - * Constructs a restricted controller messenger for the Messages controller. + * Constructs a restricted messenger for the Messages controller. * - * @param controllerMessenger - The controller messenger. - * @returns A restricted controller messenger for the Messages controller. + * @param messenger - The messenger. + * @returns A restricted messenger for the Messages controller. */ function getMessagesMessenger( - controllerMessenger?: ControllerMessenger< - MessagesControllerAction, - MessagesControllerEvent - >, + messenger?: Messenger, ): MessagesMessenger { - if (!controllerMessenger) { - controllerMessenger = new ControllerMessenger< + if (!messenger) { + messenger = new Messenger< MessagesControllerAction, MessagesControllerEvent >(); } - return controllerMessenger.getRestricted({ + return messenger.getRestricted({ name: messagesControllerName, allowedActions: [], allowedEvents: [], @@ -183,12 +173,12 @@ class MessagesController extends BaseController< describe('isBaseController', () => { it('should return true if passed a V2 controller', () => { - const controllerMessenger = new ControllerMessenger< + const messenger = new Messenger< CountControllerAction, CountControllerEvent >(); const controller = new CountController({ - messenger: getCountMessenger(controllerMessenger), + messenger: getCountMessenger(messenger), name: countControllerName, state: { count: 0 }, metadata: countControllerStateMetadata, @@ -196,14 +186,8 @@ describe('isBaseController', () => { expect(isBaseController(controller)).toBe(true); }); - it('should return false if passed a V1 controller', () => { - const controller = new TestController(); - expect(isBaseController(controller)).toBe(false); - }); - it('should return false if passed a non-controller', () => { const notController = new JsonRpcEngine(); - // @ts-expect-error Intentionally passing invalid input to test runtime behavior expect(isBaseController(notController)).toBe(false); }); }); @@ -225,18 +209,18 @@ describe('BaseController', () => { }); it('should allow getting state via the getState action', () => { - const controllerMessenger = new ControllerMessenger< + const messenger = new Messenger< CountControllerAction, CountControllerEvent >(); new CountController({ - messenger: getCountMessenger(controllerMessenger), + messenger: getCountMessenger(messenger), name: countControllerName, state: { count: 0 }, metadata: countControllerStateMetadata, }); - expect(controllerMessenger.call('CountController:getState')).toStrictEqual({ + expect(messenger.call('CountController:getState')).toStrictEqual({ count: 0, }); }); @@ -406,19 +390,16 @@ describe('BaseController', () => { }); it('should inform subscribers of state changes as a result of applying patches', () => { - const controllerMessenger = new ControllerMessenger< - never, - CountControllerEvent - >(); + const messenger = new Messenger(); const controller = new CountController({ - messenger: getCountMessenger(controllerMessenger), + messenger: getCountMessenger(messenger), name: 'CountController', state: { count: 0 }, metadata: countControllerStateMetadata, }); const listener1 = sinon.stub(); - controllerMessenger.subscribe('CountController:stateChange', listener1); + messenger.subscribe('CountController:stateChange', listener1); const { inversePatches } = controller.update(() => { return { count: 1 }; }); @@ -438,12 +419,9 @@ describe('BaseController', () => { }); it('should inform subscribers of state changes', () => { - const controllerMessenger = new ControllerMessenger< - never, - CountControllerEvent - >(); + const messenger = new Messenger(); const controller = new CountController({ - messenger: getCountMessenger(controllerMessenger), + messenger: getCountMessenger(messenger), name: 'CountController', state: { count: 0 }, metadata: countControllerStateMetadata, @@ -451,8 +429,8 @@ describe('BaseController', () => { const listener1 = sinon.stub(); const listener2 = sinon.stub(); - controllerMessenger.subscribe('CountController:stateChange', listener1); - controllerMessenger.subscribe('CountController:stateChange', listener2); + messenger.subscribe('CountController:stateChange', listener1); + messenger.subscribe('CountController:stateChange', listener2); controller.update(() => { return { count: 1 }; }); @@ -470,18 +448,15 @@ describe('BaseController', () => { }); it('should notify a subscriber with a selector of state changes', () => { - const controllerMessenger = new ControllerMessenger< - never, - CountControllerEvent - >(); + const messenger = new Messenger(); const controller = new CountController({ - messenger: getCountMessenger(controllerMessenger), + messenger: getCountMessenger(messenger), name: 'CountController', state: { count: 0 }, metadata: countControllerStateMetadata, }); const listener = sinon.stub(); - controllerMessenger.subscribe( + messenger.subscribe( 'CountController:stateChange', listener, ({ count }) => { @@ -499,18 +474,15 @@ describe('BaseController', () => { }); it('should not inform a subscriber of state changes if the selected value is unchanged', () => { - const controllerMessenger = new ControllerMessenger< - never, - CountControllerEvent - >(); + const messenger = new Messenger(); const controller = new CountController({ - messenger: getCountMessenger(controllerMessenger), + messenger: getCountMessenger(messenger), name: 'CountController', state: { count: 0 }, metadata: countControllerStateMetadata, }); const listener = sinon.stub(); - controllerMessenger.subscribe( + messenger.subscribe( 'CountController:stateChange', listener, ({ count }) => { @@ -528,20 +500,17 @@ describe('BaseController', () => { }); it('should inform a subscriber of each state change once even after multiple subscriptions', () => { - const controllerMessenger = new ControllerMessenger< - never, - CountControllerEvent - >(); + const messenger = new Messenger(); const controller = new CountController({ - messenger: getCountMessenger(controllerMessenger), + messenger: getCountMessenger(messenger), name: 'CountController', state: { count: 0 }, metadata: countControllerStateMetadata, }); const listener1 = sinon.stub(); - controllerMessenger.subscribe('CountController:stateChange', listener1); - controllerMessenger.subscribe('CountController:stateChange', listener1); + messenger.subscribe('CountController:stateChange', listener1); + messenger.subscribe('CountController:stateChange', listener1); controller.update(() => { return { count: 1 }; @@ -555,20 +524,17 @@ describe('BaseController', () => { }); it('should no longer inform a subscriber about state changes after unsubscribing', () => { - const controllerMessenger = new ControllerMessenger< - never, - CountControllerEvent - >(); + const messenger = new Messenger(); const controller = new CountController({ - messenger: getCountMessenger(controllerMessenger), + messenger: getCountMessenger(messenger), name: 'CountController', state: { count: 0 }, metadata: countControllerStateMetadata, }); const listener1 = sinon.stub(); - controllerMessenger.subscribe('CountController:stateChange', listener1); - controllerMessenger.unsubscribe('CountController:stateChange', listener1); + messenger.subscribe('CountController:stateChange', listener1); + messenger.unsubscribe('CountController:stateChange', listener1); controller.update(() => { return { count: 1 }; }); @@ -577,21 +543,18 @@ describe('BaseController', () => { }); it('should no longer inform a subscriber about state changes after unsubscribing once, even if they subscribed many times', () => { - const controllerMessenger = new ControllerMessenger< - never, - CountControllerEvent - >(); + const messenger = new Messenger(); const controller = new CountController({ - messenger: getCountMessenger(controllerMessenger), + messenger: getCountMessenger(messenger), name: 'CountController', state: { count: 0 }, metadata: countControllerStateMetadata, }); const listener1 = sinon.stub(); - controllerMessenger.subscribe('CountController:stateChange', listener1); - controllerMessenger.subscribe('CountController:stateChange', listener1); - controllerMessenger.unsubscribe('CountController:stateChange', listener1); + messenger.subscribe('CountController:stateChange', listener1); + messenger.subscribe('CountController:stateChange', listener1); + messenger.unsubscribe('CountController:stateChange', listener1); controller.update(() => { return { count: 1 }; }); @@ -600,12 +563,9 @@ describe('BaseController', () => { }); it('should throw when unsubscribing listener who was never subscribed', () => { - const controllerMessenger = new ControllerMessenger< - never, - CountControllerEvent - >(); + const messenger = new Messenger(); new CountController({ - messenger: getCountMessenger(controllerMessenger), + messenger: getCountMessenger(messenger), name: 'CountController', state: { count: 0 }, metadata: countControllerStateMetadata, @@ -613,17 +573,14 @@ describe('BaseController', () => { const listener1 = sinon.stub(); expect(() => { - controllerMessenger.unsubscribe('CountController:stateChange', listener1); + messenger.unsubscribe('CountController:stateChange', listener1); }).toThrow('Subscription not found for event: CountController:stateChange'); }); it('should no longer update subscribers after being destroyed', () => { - const controllerMessenger = new ControllerMessenger< - never, - CountControllerEvent - >(); + const messenger = new Messenger(); const controller = new CountController({ - messenger: getCountMessenger(controllerMessenger), + messenger: getCountMessenger(messenger), name: 'CountController', state: { count: 0 }, metadata: countControllerStateMetadata, @@ -631,8 +588,8 @@ describe('BaseController', () => { const listener1 = sinon.stub(); const listener2 = sinon.stub(); - controllerMessenger.subscribe('CountController:stateChange', listener1); - controllerMessenger.subscribe('CountController:stateChange', listener2); + messenger.subscribe('CountController:stateChange', listener1); + messenger.subscribe('CountController:stateChange', listener2); controller.destroy(); controller.update(() => { return { count: 1 }; @@ -1025,7 +982,7 @@ describe('getPersistentState', () => { }, }; - type VisitorMessenger = RestrictedControllerMessenger< + type VisitorMessenger = RestrictedMessenger< typeof visitorName, VisitorControllerAction | VisitorOverflowControllerAction, VisitorControllerEvent | VisitorOverflowControllerEvent, @@ -1089,7 +1046,7 @@ describe('getPersistentState', () => { }, }; - type VisitorOverflowMessenger = RestrictedControllerMessenger< + type VisitorOverflowMessenger = RestrictedMessenger< typeof visitorOverflowName, VisitorControllerAction | VisitorOverflowControllerAction, VisitorControllerEvent | VisitorOverflowControllerEvent, @@ -1139,11 +1096,11 @@ describe('getPersistentState', () => { } it('should allow messaging between controllers', () => { - const controllerMessenger = new ControllerMessenger< + const messenger = new Messenger< VisitorControllerAction | VisitorOverflowControllerAction, VisitorControllerEvent | VisitorOverflowControllerEvent >(); - const visitorControllerMessenger = controllerMessenger.getRestricted({ + const visitorControllerMessenger = messenger.getRestricted({ name: visitorName, allowedActions: [], allowedEvents: [], @@ -1151,17 +1108,16 @@ describe('getPersistentState', () => { const visitorController = new VisitorController( visitorControllerMessenger, ); - const visitorOverflowControllerMessenger = - controllerMessenger.getRestricted({ - name: visitorOverflowName, - allowedActions: ['VisitorController:clear'], - allowedEvents: ['VisitorController:stateChange'], - }); + const visitorOverflowControllerMessenger = messenger.getRestricted({ + name: visitorOverflowName, + allowedActions: ['VisitorController:clear'], + allowedEvents: ['VisitorController:stateChange'], + }); const visitorOverflowController = new VisitorOverflowController( visitorOverflowControllerMessenger, ); - controllerMessenger.call('VisitorOverflowController:updateMax', 2); + messenger.call('VisitorOverflowController:updateMax', 2); visitorController.addVisitor('A'); visitorController.addVisitor('B'); visitorController.addVisitor('C'); // this should trigger an overflow diff --git a/packages/base-controller/src/BaseControllerV2.ts b/packages/base-controller/src/BaseControllerV2.ts index 1dd2bb05b49..75022d96320 100644 --- a/packages/base-controller/src/BaseControllerV2.ts +++ b/packages/base-controller/src/BaseControllerV2.ts @@ -2,14 +2,10 @@ import type { Json, PublicInterface } from '@metamask/utils'; import { enablePatches, produceWithPatches, applyPatches, freeze } from 'immer'; import type { Draft, Patch } from 'immer'; -import type { - BaseControllerV1Instance, - StateConstraint as StateConstraintV1, -} from './BaseControllerV1'; import type { ActionConstraint, EventConstraint } from './Messenger'; import type { - RestrictedControllerMessenger, - RestrictedControllerMessengerConstraint, + RestrictedMessenger, + RestrictedMessengerConstraint, } from './RestrictedMessenger'; enablePatches(); @@ -21,9 +17,11 @@ enablePatches(); * @returns True if the controller is an instance of `BaseController` */ export function isBaseController( - controller: ControllerInstance, + controller: unknown, ): controller is BaseControllerInstance { return ( + typeof controller === 'object' && + controller !== null && 'name' in controller && typeof controller.name === 'string' && 'state' in controller && @@ -40,14 +38,6 @@ export function isBaseController( */ export type StateConstraint = Record; -/** - * A universal supertype for the controller state object, encompassing both `BaseControllerV1` and `BaseControllerV2` state. - */ -// TODO: Remove once BaseControllerV2 migrations are completed for all controllers. -export type LegacyControllerStateConstraint = - | StateConstraintV1 - | StateConstraint; - /** * A state change listener. * @@ -135,26 +125,13 @@ export type StateMetadataConstraint = Record< */ export type BaseControllerInstance = Omit< PublicInterface< - BaseController< - string, - StateConstraint, - RestrictedControllerMessengerConstraint - > + BaseController >, 'metadata' > & { metadata: StateMetadataConstraint; }; -/** - * A widest subtype of all controller instances that inherit from `BaseController` (formerly `BaseControllerV2`) or `BaseControllerV1`. - * Any `BaseController` or `BaseControllerV1` subclass instance can be assigned to this type. - */ -// TODO: Remove once BaseControllerV2 migrations are completed for all controllers. -export type ControllerInstance = - | BaseControllerV1Instance - | BaseControllerInstance; - export type ControllerGetStateAction< ControllerName extends string, ControllerState extends StateConstraint, @@ -189,7 +166,7 @@ export class BaseController< ControllerState extends StateConstraint, // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/naming-convention - messenger extends RestrictedControllerMessenger< + messenger extends RestrictedMessenger< ControllerName, ActionConstraint | ControllerActions, EventConstraint | ControllerEvents, diff --git a/packages/base-controller/src/Messenger.ts b/packages/base-controller/src/Messenger.ts index 4d10201913e..cd6feed628f 100644 --- a/packages/base-controller/src/Messenger.ts +++ b/packages/base-controller/src/Messenger.ts @@ -407,7 +407,7 @@ export class Messenger< * namespace. Ownership allows registering actions and publishing events, as well as * unregistering actions and clearing event subscriptions. * - * @param options - Controller messenger options. + * @param options - Messenger options. * @param options.name - The name of the thing this messenger will be handed to (e.g. the * controller name). This grants "ownership" of actions and events under this namespace to the * restricted messenger returned. @@ -458,5 +458,3 @@ export class Messenger< }); } } - -export { Messenger as ControllerMessenger }; diff --git a/packages/base-controller/src/RestrictedMessenger.test.ts b/packages/base-controller/src/RestrictedMessenger.test.ts index c0c2a66115f..f14990f45ee 100644 --- a/packages/base-controller/src/RestrictedMessenger.test.ts +++ b/packages/base-controller/src/RestrictedMessenger.test.ts @@ -16,23 +16,6 @@ describe('RestrictedMessenger', () => { ).toThrow('Messenger not provided'); }); - it('should throw if both controllerMessenger and messenger are provided', () => { - const messenger = new Messenger(); - - expect( - () => - new RestrictedMessenger({ - controllerMessenger: messenger, - messenger, - name: 'Test', - allowedActions: [], - allowedEvents: [], - }), - ).toThrow( - `Both messenger properties provided. Provide message using only 'messenger' option, 'controllerMessenger' is deprecated`, - ); - }); - it('should accept messenger parameter', () => { type CountAction = { type: 'CountController:count'; @@ -63,37 +46,6 @@ describe('RestrictedMessenger', () => { expect(count).toBe(1); }); - - it('should accept controllerMessenger parameter', () => { - type CountAction = { - type: 'CountController:count'; - handler: (increment: number) => void; - }; - const messenger = new Messenger(); - const restrictedMessenger = new RestrictedMessenger< - 'CountController', - CountAction, - never, - never, - never - >({ - controllerMessenger: messenger, - name: 'CountController', - allowedActions: [], - allowedEvents: [], - }); - - let count = 0; - restrictedMessenger.registerActionHandler( - 'CountController:count', - (increment: number) => { - count += increment; - }, - ); - restrictedMessenger.call('CountController:count', 1); - - expect(count).toBe(1); - }); }); it('should allow registering and calling an action handler', () => { diff --git a/packages/base-controller/src/RestrictedMessenger.ts b/packages/base-controller/src/RestrictedMessenger.ts index c1cd62b6ad1..59be9e03592 100644 --- a/packages/base-controller/src/RestrictedMessenger.ts +++ b/packages/base-controller/src/RestrictedMessenger.ts @@ -29,18 +29,6 @@ export type RestrictedMessengerConstraint = string >; -/** - * A universal supertype of all `RestrictedMessenger` instances. This type can be assigned to any - * `RestrictedMessenger` type. - * - * @template Namespace - Name of the module this messenger is for. Optionally can be used to - * narrow this type to a constraint for the messenger of a specific module. - * @deprecated This has been renamed to `RestrictedMessengerConstraint`. - */ -export type RestrictedControllerMessengerConstraint< - Namespace extends string = string, -> = RestrictedMessengerConstraint; - /** * A restricted messenger. * @@ -81,7 +69,6 @@ export class RestrictedMessenger< * unregistering actions and clearing event subscriptions. * * @param options - Options. - * @param options.controllerMessenger - The messenger instance that is being wrapped. (deprecated) * @param options.messenger - The messenger instance that is being wrapped. * @param options.name - The name of the thing this messenger will be handed to (e.g. the * controller name). This grants "ownership" of actions and events under this namespace to the @@ -92,28 +79,21 @@ export class RestrictedMessenger< * allowed to subscribe to. */ constructor({ - controllerMessenger, messenger, name, allowedActions, allowedEvents, }: { - controllerMessenger?: Messenger; messenger?: Messenger; name: Namespace; allowedActions: NotNamespacedBy[]; allowedEvents: NotNamespacedBy[]; }) { - if (messenger && controllerMessenger) { - throw new Error( - `Both messenger properties provided. Provide message using only 'messenger' option, 'controllerMessenger' is deprecated`, - ); - } else if (!messenger && !controllerMessenger) { + if (!messenger) { throw new Error('Messenger not provided'); } // The above condition guarantees that one of these options is defined. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.#messenger = (messenger ?? controllerMessenger)!; + this.#messenger = messenger; this.#namespace = name; this.#allowedActions = allowedActions; this.#allowedEvents = allowedEvents; @@ -429,5 +409,3 @@ export class RestrictedMessenger< return name.startsWith(`${this.#namespace}:`); } } - -export { RestrictedMessenger as RestrictedControllerMessenger }; diff --git a/packages/base-controller/src/index.ts b/packages/base-controller/src/index.ts index af19ddfc505..b2d3154d1b1 100644 --- a/packages/base-controller/src/index.ts +++ b/packages/base-controller/src/index.ts @@ -1,18 +1,7 @@ -export type { - BaseConfig, - BaseControllerV1Instance, - BaseState, - ConfigConstraint as ConfigConstraintV1, - Listener, - StateConstraint as StateConstraintV1, -} from './BaseControllerV1'; -export { BaseControllerV1, isBaseControllerV1 } from './BaseControllerV1'; export type { BaseControllerInstance, - ControllerInstance, Listener as ListenerV2, StateConstraint, - LegacyControllerStateConstraint, StateDeriver, StateDeriverConstraint, StateMetadata, @@ -42,12 +31,6 @@ export type { NotNamespacedBy, NamespacedName, } from './Messenger'; -export { ControllerMessenger, Messenger } from './Messenger'; -export type { - RestrictedControllerMessengerConstraint, - RestrictedMessengerConstraint, -} from './RestrictedMessenger'; -export { - RestrictedControllerMessenger, - RestrictedMessenger, -} from './RestrictedMessenger'; +export { Messenger } from './Messenger'; +export type { RestrictedMessengerConstraint } from './RestrictedMessenger'; +export { RestrictedMessenger } from './RestrictedMessenger'; diff --git a/packages/base-controller/tests/helpers.ts b/packages/base-controller/tests/helpers.ts index 3aa8ccd2779..f31cc0d1659 100644 --- a/packages/base-controller/tests/helpers.ts +++ b/packages/base-controller/tests/helpers.ts @@ -1,15 +1,9 @@ -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; +import type { RestrictedMessenger } from '@metamask/base-controller'; /* eslint-disable @typescript-eslint/no-explicit-any */ // We don't care about the types marked with `any` for this type. export type ExtractAvailableAction = - Messenger extends RestrictedControllerMessenger< - any, - infer Action, - any, - any, - any - > + Messenger extends RestrictedMessenger ? Action : never; /* eslint-enable @typescript-eslint/no-explicit-any */ @@ -17,13 +11,7 @@ export type ExtractAvailableAction = /* eslint-disable @typescript-eslint/no-explicit-any */ // We don't care about the types marked with `any` for this type. export type ExtractAvailableEvent = - Messenger extends RestrictedControllerMessenger< - any, - any, - infer Event, - any, - any - > + Messenger extends RestrictedMessenger ? Event : never; /* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md new file mode 100644 index 00000000000..8869e80b3be --- /dev/null +++ b/packages/bridge-controller/CHANGELOG.md @@ -0,0 +1,19 @@ +# 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 + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^23.0.0` to `^24.0.0` ([#5318](https://github.com/MetaMask/core/pull/5318)) +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency from `^45.0.0` to `^46.0.0` ([#5318](https://github.com/MetaMask/core/pull/5318)) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/bridge-controller/LICENSE b/packages/bridge-controller/LICENSE new file mode 100644 index 00000000000..7d002dced3a --- /dev/null +++ b/packages/bridge-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/bridge-controller/README.md b/packages/bridge-controller/README.md new file mode 100644 index 00000000000..adb050aedec --- /dev/null +++ b/packages/bridge-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/bridge-controller` + +Manages bridge-related quote fetching functionality for MetaMask. + +## Installation + +`yarn add @metamask/bridge-controller` + +or + +`npm install @metamask/bridge-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/bridge-controller/jest.config.js b/packages/bridge-controller/jest.config.js new file mode 100644 index 00000000000..d67e30322b8 --- /dev/null +++ b/packages/bridge-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: 93, + functions: 98, + lines: 99, + statements: 99, + }, + }, +}); diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json new file mode 100644 index 00000000000..4f7c837d54a --- /dev/null +++ b/packages/bridge-controller/package.json @@ -0,0 +1,87 @@ +{ + "name": "@metamask/bridge-controller", + "version": "0.0.0", + "description": "Manages bridge-related quote fetching functionality for MetaMask", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/bridge-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:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/bridge-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/bridge-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/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", + "@metamask/metamask-eth-abis": "^3.1.1", + "@metamask/polling-controller": "^12.0.3", + "@metamask/utils": "^11.1.0", + "ethers": "^6.12.0" + }, + "devDependencies": { + "@metamask/accounts-controller": "^24.0.0", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/eth-json-rpc-provider": "^4.1.8", + "@metamask/json-rpc-engine": "^10.0.3", + "@metamask/network-controller": "^22.2.1", + "@metamask/transaction-controller": "^46.0.0", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "lodash": "^4.17.21", + "nock": "^13.3.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.2.2" + }, + "peerDependencies": { + "@metamask/accounts-controller": "^24.0.0", + "@metamask/network-controller": "^22.0.0", + "@metamask/transaction-controller": "^46.0.0" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts new file mode 100644 index 00000000000..3bc04fcc973 --- /dev/null +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -0,0 +1,830 @@ +import type { Hex } from '@metamask/utils'; +import { bigIntToHex } from '@metamask/utils'; +import { Contract } from 'ethers'; +import nock from 'nock'; + +import { BridgeController } from './bridge-controller'; +import { + BridgeClientId, + DEFAULT_BRIDGE_CONTROLLER_STATE, +} from './constants/bridge'; +import { CHAIN_IDS } from './constants/chains'; +import { SWAPS_API_V2_BASE_URL } from './constants/swaps'; +import type { BridgeControllerMessenger, QuoteResponse } from './types'; +import * as balanceUtils from './utils/balance'; +import { getBridgeApiBaseUrl } from './utils/bridge'; +import * as fetchUtils from './utils/fetch'; +import { flushPromises } from '../../../tests/helpers'; +import { handleFetch } from '../../controller-utils/src'; +import mockBridgeQuotesErc20Native from '../tests/mock-quotes-erc20-native.json'; +import mockBridgeQuotesNativeErc20Eth from '../tests/mock-quotes-native-erc20-eth.json'; +import mockBridgeQuotesNativeErc20 from '../tests/mock-quotes-native-erc20.json'; + +const EMPTY_INIT_STATE = { + bridgeState: DEFAULT_BRIDGE_CONTROLLER_STATE, +}; + +const messengerMock = { + call: jest.fn(), + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + publish: jest.fn(), +} as unknown as jest.Mocked; + +jest.mock('ethers', () => { + return { + ...jest.requireActual('ethers'), + Contract: jest.fn(), + BrowserProvider: jest.fn(), + }; +}); +const getLayer1GasFeeMock = jest.fn(); +const mockFetchFn = handleFetch; + +describe('BridgeController', function () { + let bridgeController: BridgeController; + + beforeAll(function () { + bridgeController = new BridgeController({ + messenger: messengerMock, + getLayer1GasFee: getLayer1GasFeeMock, + clientId: BridgeClientId.EXTENSION, + fetchFn: mockFetchFn, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + + nock(getBridgeApiBaseUrl()) + .get('/getAllFeatureFlags') + .reply(200, { + 'extension-config': { + refreshRate: 3, + maxRefreshCount: 3, + support: true, + chains: { + '10': { + isActiveSrc: true, + isActiveDest: false, + }, + '534352': { + isActiveSrc: true, + isActiveDest: false, + }, + '137': { + isActiveSrc: false, + isActiveDest: true, + }, + '42161': { + isActiveSrc: false, + isActiveDest: true, + }, + }, + }, + 'approval-gas-multiplier': { + '137': 1.1, + '42161': 1.2, + '10': 1.3, + '534352': 1.4, + }, + 'bridge-gas-multiplier': { + '137': 2.1, + '42161': 2.2, + '10': 2.3, + '534352': 2.4, + }, + }); + nock(getBridgeApiBaseUrl()) + .get('/getTokens?chainId=10') + .reply(200, [ + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + symbol: 'ABC', + decimals: 16, + aggregators: ['lifl', 'socket'], + }, + { + address: '0x1291478912', + symbol: 'DEF', + decimals: 16, + }, + ]); + nock(SWAPS_API_V2_BASE_URL) + .get('/networks/10/topAssets') + .reply(200, [ + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + symbol: 'ABC', + }, + ]); + bridgeController.resetState(); + }); + + it('constructor should setup correctly', function () { + expect(bridgeController.state).toStrictEqual(EMPTY_INIT_STATE); + }); + + it('setBridgeFeatureFlags should fetch and set the bridge feature flags', async function () { + const expectedFeatureFlagsResponse = { + extensionConfig: { + maxRefreshCount: 3, + refreshRate: 3, + support: true, + chains: { + [CHAIN_IDS.OPTIMISM]: { isActiveSrc: true, isActiveDest: false }, + [CHAIN_IDS.SCROLL]: { isActiveSrc: true, isActiveDest: false }, + [CHAIN_IDS.POLYGON]: { isActiveSrc: false, isActiveDest: true }, + [CHAIN_IDS.ARBITRUM]: { isActiveSrc: false, isActiveDest: true }, + }, + }, + }; + expect(bridgeController.state).toStrictEqual(EMPTY_INIT_STATE); + + const setIntervalLengthSpy = jest.spyOn( + bridgeController, + 'setIntervalLength', + ); + + await bridgeController.setBridgeFeatureFlags(); + expect(bridgeController.state.bridgeState.bridgeFeatureFlags).toStrictEqual( + expectedFeatureFlagsResponse, + ); + expect(setIntervalLengthSpy).toHaveBeenCalledTimes(1); + expect(setIntervalLengthSpy).toHaveBeenCalledWith(3); + + bridgeController.resetState(); + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + bridgeFeatureFlags: expectedFeatureFlagsResponse, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + }), + ); + }); + + it('updateBridgeQuoteRequestParams should update the quoteRequest state', async function () { + await bridgeController.updateBridgeQuoteRequestParams({ srcChainId: 1 }); + expect(bridgeController.state.bridgeState.quoteRequest).toStrictEqual({ + srcChainId: 1, + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + walletAddress: undefined, + }); + + await bridgeController.updateBridgeQuoteRequestParams({ destChainId: 10 }); + expect(bridgeController.state.bridgeState.quoteRequest).toStrictEqual({ + destChainId: 10, + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + walletAddress: undefined, + }); + + await bridgeController.updateBridgeQuoteRequestParams({ + destChainId: undefined, + }); + expect(bridgeController.state.bridgeState.quoteRequest).toStrictEqual({ + destChainId: undefined, + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + walletAddress: undefined, + }); + + await bridgeController.updateBridgeQuoteRequestParams({ + srcTokenAddress: undefined, + }); + expect(bridgeController.state.bridgeState.quoteRequest).toStrictEqual({ + slippage: 0.5, + srcTokenAddress: undefined, + walletAddress: undefined, + }); + + await bridgeController.updateBridgeQuoteRequestParams({ + srcTokenAmount: '100000', + destTokenAddress: '0x123', + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + walletAddress: undefined, + }); + expect(bridgeController.state.bridgeState.quoteRequest).toStrictEqual({ + srcTokenAmount: '100000', + destTokenAddress: '0x123', + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + walletAddress: undefined, + }); + + await bridgeController.updateBridgeQuoteRequestParams({ + srcTokenAddress: '0x2ABC', + }); + expect(bridgeController.state.bridgeState.quoteRequest).toStrictEqual({ + slippage: 0.5, + srcTokenAddress: '0x2ABC', + walletAddress: undefined, + }); + + bridgeController.resetState(); + expect(bridgeController.state.bridgeState.quoteRequest).toStrictEqual({ + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + walletAddress: undefined, + }); + }); + + it('updateBridgeQuoteRequestParams should trigger quote polling if request is valid', async function () { + jest.useFakeTimers(); + const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + messengerMock.call.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve(mockBridgeQuotesNativeErc20Eth as never); + }, 5000); + }); + }); + + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve([ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ] as never); + }, 10000); + }); + }); + + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((_resolve, reject) => { + return setTimeout(() => { + reject(new Error('Network error')); + }, 10000); + }); + }); + + const quoteParams = { + srcChainId: 1, + destChainId: 10, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + }; + const quoteRequest = { + ...quoteParams, + slippage: 0.5, + walletAddress: '0x123', + }; + await bridgeController.updateBridgeQuoteRequestParams(quoteParams); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + networkClientId: expect.anything(), + updatedQuoteRequest: { + ...quoteRequest, + insufficientBal: false, + }, + }); + + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + }), + ); + + // Loading state + jest.advanceTimersByTime(1000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + { + ...quoteRequest, + insufficientBal: false, + }, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + mockFetchFn, + ); + expect( + bridgeController.state.bridgeState.quotesLastFetched, + ).toBeUndefined(); + + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, insufficientBal: false }, + quotes: [], + quotesLoadingStatus: 0, + }), + ); + + // After first fetch + jest.advanceTimersByTime(10000); + await flushPromises(); + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, insufficientBal: false }, + quotes: mockBridgeQuotesNativeErc20Eth, + quotesLoadingStatus: 1, + }), + ); + const firstFetchTime = bridgeController.state.bridgeState.quotesLastFetched; + expect(firstFetchTime).toBeGreaterThan(0); + + // After 2nd fetch + jest.advanceTimersByTime(50000); + await flushPromises(); + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, insufficientBal: false }, + quotes: [ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ], + quotesLoadingStatus: 1, + quoteFetchError: undefined, + quotesRefreshCount: 2, + }), + ); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(2); + const secondFetchTime = + bridgeController.state.bridgeState.quotesLastFetched; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(secondFetchTime).toBeGreaterThan(firstFetchTime!); + + // After 3nd fetch throws an error + jest.advanceTimersByTime(50000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, insufficientBal: false }, + quotes: [ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ], + quotesLoadingStatus: 2, + quoteFetchError: 'Network error', + quotesRefreshCount: 3, + }), + ); + expect( + bridgeController.state.bridgeState.quotesLastFetched, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ).toBeGreaterThan(secondFetchTime!); + + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); + }); + + it('updateBridgeQuoteRequestParams should only poll once if insufficientBal=true', async function () { + jest.useFakeTimers(); + const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(false); + messengerMock.call.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve(mockBridgeQuotesNativeErc20Eth as never); + }, 5000); + }); + }); + + fetchBridgeQuotesSpy.mockImplementation(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve([ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ] as never); + }, 10000); + }); + }); + + const quoteParams = { + srcChainId: 1, + destChainId: 10, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + }; + const quoteRequest = { + ...quoteParams, + slippage: 0.5, + walletAddress: '0x123', + }; + await bridgeController.updateBridgeQuoteRequestParams(quoteParams); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + networkClientId: expect.anything(), + updatedQuoteRequest: { + ...quoteRequest, + insufficientBal: true, + }, + }); + + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesInitialLoadTime: undefined, + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + }), + ); + + // Loading state + jest.advanceTimersByTime(1000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + { + ...quoteRequest, + insufficientBal: true, + }, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + mockFetchFn, + ); + expect( + bridgeController.state.bridgeState.quotesLastFetched, + ).toBeUndefined(); + + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, insufficientBal: true }, + quotes: [], + quotesLoadingStatus: 0, + }), + ); + + // After first fetch + jest.advanceTimersByTime(10000); + await flushPromises(); + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, insufficientBal: true }, + quotes: mockBridgeQuotesNativeErc20Eth, + quotesLoadingStatus: 1, + quotesRefreshCount: 1, + quotesInitialLoadTime: 11000, + }), + ); + const firstFetchTime = bridgeController.state.bridgeState.quotesLastFetched; + expect(firstFetchTime).toBeGreaterThan(0); + + // After 2nd fetch + jest.advanceTimersByTime(50000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, insufficientBal: true }, + quotes: mockBridgeQuotesNativeErc20Eth, + quotesLoadingStatus: 1, + quotesRefreshCount: 1, + quotesInitialLoadTime: 11000, + }), + ); + const secondFetchTime = + bridgeController.state.bridgeState.quotesLastFetched; + expect(secondFetchTime).toStrictEqual(firstFetchTime); + expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); + }); + + it('updateBridgeQuoteRequestParams should not trigger quote polling if request is invalid', async function () { + const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + messengerMock.call.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); + + await bridgeController.updateBridgeQuoteRequestParams({ + srcChainId: 1, + destChainId: 10, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + }); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).not.toHaveBeenCalled(); + + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { + srcChainId: 1, + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + walletAddress: undefined, + destChainId: 10, + destTokenAddress: '0x123', + }, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + }), + ); + }); + + 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.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); + + const allowance = await bridgeController.getBridgeERC20Allowance( + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + '0xa', + ); + expect(allowance).toBe('100000000000000000000'); + }); + + it('should throw an error when no provider is found', async () => { + // Setup + const mockMessenger = { + call: jest.fn().mockImplementation((methodName) => { + // eslint-disable-next-line jest/no-conditional-in-test + if (methodName === 'NetworkController:getNetworkClientById') { + return { provider: null }; + } + // eslint-disable-next-line jest/no-conditional-in-test + if (methodName === 'NetworkController:getState') { + return { selectedNetworkClientId: 'testNetworkClientId' }; + } + 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, + getLayer1GasFee: jest.fn(), + fetchFn: mockFetchFn, + }); + + // 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', + mockBridgeQuotesErc20Native as QuoteResponse[], + bigIntToHex(BigInt('2608710388388') * 2n), + 12, + ], + [ + 'should append l1GasFees if srcChain is 10 and srcToken is native', + mockBridgeQuotesNativeErc20 as unknown as QuoteResponse[], + bigIntToHex(BigInt('2608710388388')), + 2, + ], + [ + 'should not append l1GasFees if srcChain is not 10', + mockBridgeQuotesNativeErc20Eth as unknown as QuoteResponse[], + undefined, + 0, + ], + ])( + 'updateBridgeQuoteRequestParams: %s', + async ( + _testTitle: string, + quoteResponse: QuoteResponse[], + l1GasFeesInHexWei: Hex | undefined, + getLayer1GasFeeMockCallCount: number, + ) => { + jest.useFakeTimers(); + const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(false); + messengerMock.call.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); + getLayer1GasFeeMock.mockResolvedValue('0x25F63418AA4'); + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve(quoteResponse as never); + }, 1000); + }); + }); + + const quoteParams = { + srcChainId: 10, + destChainId: 1, + srcTokenAddress: '0x4200000000000000000000000000000000000006', + destTokenAddress: '0x0000000000000000000000000000000000000000', + srcTokenAmount: '991250000000000000', + }; + const quoteRequest = { + ...quoteParams, + slippage: 0.5, + walletAddress: '0x123', + }; + await bridgeController.updateBridgeQuoteRequestParams(quoteParams); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + networkClientId: expect.anything(), + updatedQuoteRequest: { + ...quoteRequest, + insufficientBal: true, + }, + }); + + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + }), + ); + + // // Loading state + jest.advanceTimersByTime(500); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + { + ...quoteRequest, + insufficientBal: true, + }, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + mockFetchFn, + ); + expect( + bridgeController.state.bridgeState.quotesLastFetched, + ).toBeUndefined(); + + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, insufficientBal: true }, + quotes: [], + quotesLoadingStatus: 0, + }), + ); + + // After first fetch + jest.advanceTimersByTime(1500); + await flushPromises(); + const { quotes } = bridgeController.state.bridgeState; + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, insufficientBal: true }, + quotesLoadingStatus: 1, + quotesRefreshCount: 1, + }), + ); + quotes.forEach((quote) => { + const expectedQuote = { ...quote, l1GasFeesInHexWei }; + // eslint-disable-next-line jest/prefer-strict-equal + expect(quote).toEqual(expectedQuote); + }); + + const firstFetchTime = + bridgeController.state.bridgeState.quotesLastFetched; + expect(firstFetchTime).toBeGreaterThan(0); + + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes( + getLayer1GasFeeMockCallCount, + ); + }, + ); + + it('should not fetch quotes if source and destination chains are the same', async () => { + jest.useFakeTimers(); + const fetchBridgeQuotesSpy = jest.spyOn(fetchUtils, 'fetchBridgeQuotes'); + messengerMock.call.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); + + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + + const quoteParams = { + srcChainId: 1, + destChainId: 1, // Same chain ID + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + }; + + await bridgeController.updateBridgeQuoteRequestParams(quoteParams); + + // Advance timers to trigger fetch + jest.advanceTimersByTime(1000); + await flushPromises(); + + expect(fetchBridgeQuotesSpy).not.toHaveBeenCalled(); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state.bridgeState.quotesLoadingStatus).toBe( + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + ); + }); + + it('should handle abort signals in fetchBridgeQuotes', async () => { + jest.useFakeTimers(); + const fetchBridgeQuotesSpy = jest.spyOn(fetchUtils, 'fetchBridgeQuotes'); + messengerMock.call.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); + + jest.spyOn(balanceUtils, 'hasSufficientBalance').mockResolvedValue(true); + + // Mock fetchBridgeQuotes to throw AbortError + fetchBridgeQuotesSpy.mockImplementation(async () => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + throw error; + }); + + const quoteParams = { + srcChainId: 1, + destChainId: 10, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + }; + + await bridgeController.updateBridgeQuoteRequestParams(quoteParams); + + // Advance timers to trigger fetch + jest.advanceTimersByTime(1000); + await flushPromises(); + + // Verify state wasn't updated due to abort + expect(bridgeController.state.bridgeState.quoteFetchError).toBeUndefined(); + expect(bridgeController.state.bridgeState.quotesLoadingStatus).toBe(0); + expect(bridgeController.state.bridgeState.quotes).toStrictEqual([]); + + // Test reset abort + fetchBridgeQuotesSpy.mockRejectedValueOnce('Reset controller state'); + + await bridgeController.updateBridgeQuoteRequestParams(quoteParams); + + jest.advanceTimersByTime(1000); + await flushPromises(); + + // Verify state wasn't updated due to reset + expect(bridgeController.state.bridgeState.quoteFetchError).toBeUndefined(); + expect(bridgeController.state.bridgeState.quotesLoadingStatus).toBe(0); + expect(bridgeController.state.bridgeState.quotes).toStrictEqual([]); + }); +}); diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts new file mode 100644 index 00000000000..7788d2b7561 --- /dev/null +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -0,0 +1,375 @@ +import type { StateMetadata } from '@metamask/base-controller'; +import type { ChainId } from '@metamask/controller-utils'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { NetworkClientId } from '@metamask/network-controller'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { TransactionParams } from '@metamask/transaction-controller'; +import { numberToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; +import { BrowserProvider, Contract } from 'ethers'; + +import type { BridgeClientId } from './constants/bridge'; +import { REFRESH_INTERVAL_MS } from './constants/bridge'; +import { + BRIDGE_CONTROLLER_NAME, + DEFAULT_BRIDGE_CONTROLLER_STATE, + METABRIDGE_CHAIN_TO_ADDRESS_MAP, +} from './constants/bridge'; +import { CHAIN_IDS } from './constants/chains'; +import { + type L1GasFees, + type QuoteRequest, + type QuoteResponse, + type TxData, + type BridgeControllerState, + BridgeFeatureFlagsKey, + RequestStatus, +} from './types'; +import type { BridgeControllerMessenger, FetchFunction } from './types'; +import { hasSufficientBalance } from './utils/balance'; +import { getDefaultBridgeControllerState, sumHexes } from './utils/bridge'; +import { fetchBridgeFeatureFlags, fetchBridgeQuotes } from './utils/fetch'; +import { isValidQuoteRequest } from './utils/quote'; + +const metadata: StateMetadata<{ bridgeState: BridgeControllerState }> = { + bridgeState: { + persist: false, + anonymous: false, + }, +}; + +const RESET_STATE_ABORT_MESSAGE = 'Reset controller state'; + +/** The input to start polling for the {@link BridgeController} */ +type BridgePollingInput = { + networkClientId: NetworkClientId; + updatedQuoteRequest: QuoteRequest; +}; + +export class BridgeController extends StaticIntervalPollingController()< + typeof BRIDGE_CONTROLLER_NAME, + { bridgeState: BridgeControllerState }, + BridgeControllerMessenger +> { + #abortController: AbortController | undefined; + + #quotesFirstFetched: number | undefined; + + readonly #clientId: string; + + readonly #getLayer1GasFee: (params: { + transactionParams: TransactionParams; + chainId: ChainId; + }) => Promise; + + readonly #fetchFn: FetchFunction; + + constructor({ + messenger, + state, + clientId, + getLayer1GasFee, + fetchFn, + }: { + messenger: BridgeControllerMessenger; + state?: Partial; + clientId: BridgeClientId; + getLayer1GasFee: (params: { + transactionParams: TransactionParams; + chainId: ChainId; + }) => Promise; + fetchFn: FetchFunction; + }) { + super({ + name: BRIDGE_CONTROLLER_NAME, + metadata, + messenger, + state: { + bridgeState: { + ...getDefaultBridgeControllerState(), + ...state, + }, + }, + }); + + this.setIntervalLength(REFRESH_INTERVAL_MS); + + this.#abortController = new AbortController(); + this.#getLayer1GasFee = getLayer1GasFee; + this.#clientId = clientId; + this.#fetchFn = fetchFn; + + // Register action handlers + this.messagingSystem.registerActionHandler( + `${BRIDGE_CONTROLLER_NAME}:setBridgeFeatureFlags`, + this.setBridgeFeatureFlags.bind(this), + ); + this.messagingSystem.registerActionHandler( + `${BRIDGE_CONTROLLER_NAME}:updateBridgeQuoteRequestParams`, + this.updateBridgeQuoteRequestParams.bind(this), + ); + this.messagingSystem.registerActionHandler( + `${BRIDGE_CONTROLLER_NAME}:resetState`, + this.resetState.bind(this), + ); + this.messagingSystem.registerActionHandler( + `${BRIDGE_CONTROLLER_NAME}:getBridgeERC20Allowance`, + this.getBridgeERC20Allowance.bind(this), + ); + } + + _executePoll = async (pollingInput: BridgePollingInput) => { + await this.#fetchBridgeQuotes(pollingInput); + }; + + updateBridgeQuoteRequestParams = async ( + paramsToUpdate: Partial, + ) => { + this.stopAllPolling(); + this.#abortController?.abort('Quote request updated'); + + const updatedQuoteRequest = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest, + ...paramsToUpdate, + }; + + this.update((state) => { + state.bridgeState.quoteRequest = updatedQuoteRequest; + state.bridgeState.quotes = DEFAULT_BRIDGE_CONTROLLER_STATE.quotes; + state.bridgeState.quotesLastFetched = + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched; + state.bridgeState.quotesLoadingStatus = + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus; + state.bridgeState.quoteFetchError = + DEFAULT_BRIDGE_CONTROLLER_STATE.quoteFetchError; + state.bridgeState.quotesRefreshCount = + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesRefreshCount; + state.bridgeState.quotesInitialLoadTime = + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesInitialLoadTime; + }); + + if (isValidQuoteRequest(updatedQuoteRequest)) { + this.#quotesFirstFetched = Date.now(); + const walletAddress = this.#getSelectedAccount().address; + const srcChainIdInHex = numberToHex(updatedQuoteRequest.srcChainId); + + const insufficientBal = + paramsToUpdate.insufficientBal || + !(await this.#hasSufficientBalance(updatedQuoteRequest)); + + const networkClientId = this.#getSelectedNetworkClientId(srcChainIdInHex); + this.startPolling({ + networkClientId, + updatedQuoteRequest: { + ...updatedQuoteRequest, + walletAddress, + insufficientBal, + }, + }); + } + }; + + readonly #hasSufficientBalance = async (quoteRequest: QuoteRequest) => { + const walletAddress = this.#getSelectedAccount().address; + const srcChainIdInHex = numberToHex(quoteRequest.srcChainId); + const provider = this.#getSelectedNetworkClient()?.provider; + + return ( + provider && + (await hasSufficientBalance( + provider, + walletAddress, + quoteRequest.srcTokenAddress, + quoteRequest.srcTokenAmount, + srcChainIdInHex, + )) + ); + }; + + resetState = () => { + this.stopAllPolling(); + this.#abortController?.abort(RESET_STATE_ABORT_MESSAGE); + + this.update((state) => { + state.bridgeState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quotes: [], + bridgeFeatureFlags: state.bridgeState.bridgeFeatureFlags, + }; + }); + }; + + setBridgeFeatureFlags = async () => { + const bridgeFeatureFlags = await fetchBridgeFeatureFlags( + this.#clientId, + this.#fetchFn, + ); + this.update((state) => { + state.bridgeState.bridgeFeatureFlags = bridgeFeatureFlags; + }); + this.setIntervalLength( + bridgeFeatureFlags[BridgeFeatureFlagsKey.EXTENSION_CONFIG].refreshRate, + ); + }; + + readonly #fetchBridgeQuotes = async ({ + networkClientId: _networkClientId, + updatedQuoteRequest, + }: BridgePollingInput) => { + const { bridgeState } = this.state; + this.#abortController?.abort('New quote request'); + this.#abortController = new AbortController(); + if (updatedQuoteRequest.srcChainId === updatedQuoteRequest.destChainId) { + return; + } + this.update((state) => { + state.bridgeState.quotesLoadingStatus = RequestStatus.LOADING; + state.bridgeState.quoteRequest = updatedQuoteRequest; + state.bridgeState.quoteFetchError = + DEFAULT_BRIDGE_CONTROLLER_STATE.quoteFetchError; + }); + + try { + const quotes = await fetchBridgeQuotes( + updatedQuoteRequest, + // AbortController is always defined by this line, because we assign it a few lines above, + // not sure why Jest thinks it's not + // Linters accurately say that it's defined + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.#abortController!.signal as AbortSignal, + this.#clientId, + this.#fetchFn, + ); + + const quotesWithL1GasFees = await this.#appendL1GasFees(quotes); + + this.update((state) => { + state.bridgeState.quotes = quotesWithL1GasFees; + state.bridgeState.quotesLoadingStatus = RequestStatus.FETCHED; + }); + } catch (error) { + const isAbortError = (error as Error).name === 'AbortError'; + const isAbortedDueToReset = error === RESET_STATE_ABORT_MESSAGE; + if (isAbortedDueToReset || isAbortError) { + return; + } + + this.update((state) => { + state.bridgeState.quoteFetchError = + error instanceof Error ? error.message : 'Unknown error'; + state.bridgeState.quotesLoadingStatus = RequestStatus.ERROR; + }); + console.log('Failed to fetch bridge quotes', error); + } finally { + const { maxRefreshCount } = + bridgeState.bridgeFeatureFlags[BridgeFeatureFlagsKey.EXTENSION_CONFIG]; + + const updatedQuotesRefreshCount = bridgeState.quotesRefreshCount + 1; + // Stop polling if the maximum number of refreshes has been reached + if ( + updatedQuoteRequest.insufficientBal || + (!updatedQuoteRequest.insufficientBal && + updatedQuotesRefreshCount >= maxRefreshCount) + ) { + this.stopAllPolling(); + } + + // Update quote fetching stats + const quotesLastFetched = Date.now(); + this.update((state) => { + state.bridgeState.quotesInitialLoadTime = + updatedQuotesRefreshCount === 1 && this.#quotesFirstFetched + ? quotesLastFetched - this.#quotesFirstFetched + : bridgeState.quotesInitialLoadTime; + state.bridgeState.quotesLastFetched = quotesLastFetched; + state.bridgeState.quotesRefreshCount = updatedQuotesRefreshCount; + }); + } + }; + + readonly #appendL1GasFees = async ( + quotes: QuoteResponse[], + ): Promise<(QuoteResponse & L1GasFees)[]> => { + return await Promise.all( + quotes.map(async (quoteResponse) => { + const { quote, trade, approval } = quoteResponse; + const chainId = numberToHex(quote.srcChainId) as ChainId; + if ( + [CHAIN_IDS.OPTIMISM.toString(), CHAIN_IDS.BASE.toString()].includes( + chainId, + ) + ) { + const getTxParams = (txData: TxData) => ({ + from: txData.from, + to: txData.to, + value: txData.value, + data: txData.data, + gasLimit: txData.gasLimit?.toString(), + }); + const approvalL1GasFees = approval + ? await this.#getLayer1GasFee({ + transactionParams: getTxParams(approval), + chainId, + }) + : '0'; + const tradeL1GasFees = await this.#getLayer1GasFee({ + transactionParams: getTxParams(trade), + chainId, + }); + return { + ...quoteResponse, + l1GasFeesInHexWei: sumHexes(approvalL1GasFees, tradeL1GasFees), + }; + } + return quoteResponse; + }), + ); + }; + + #getSelectedAccount() { + return this.messagingSystem.call('AccountsController:getSelectedAccount'); + } + + #getSelectedNetworkClient() { + const { selectedNetworkClientId } = this.messagingSystem.call( + 'NetworkController:getState', + ); + const networkClient = this.messagingSystem.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, + ); + return networkClient; + } + + #getSelectedNetworkClientId(chainId: Hex) { + return this.messagingSystem.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + } + + /** + * + * @param contractAddress - The address of the ERC20 token contract + * @param chainId - The hex chain ID of the bridge network + * @returns The atomic allowance of the ERC20 token contract + */ + getBridgeERC20Allowance = async ( + contractAddress: string, + chainId: Hex, + ): Promise => { + const provider = this.#getSelectedNetworkClient()?.provider; + if (!provider) { + throw new Error('No provider found'); + } + + const ethersProvider = new BrowserProvider(provider); + const contract = new Contract(contractAddress, abiERC20, ethersProvider); + const { address: walletAddress } = this.#getSelectedAccount(); + const allowance: bigint = await contract.allowance( + walletAddress, + METABRIDGE_CHAIN_TO_ADDRESS_MAP[chainId], + ); + return allowance.toString(); + }; +} diff --git a/packages/bridge-controller/src/constants/bridge.ts b/packages/bridge-controller/src/constants/bridge.ts new file mode 100644 index 00000000000..2fc2500b19c --- /dev/null +++ b/packages/bridge-controller/src/constants/bridge.ts @@ -0,0 +1,68 @@ +import type { Hex } from '@metamask/utils'; +import { ZeroAddress } from 'ethers'; + +import { CHAIN_IDS } from './chains'; +import type { BridgeControllerState } from '../types'; +import { BridgeFeatureFlagsKey } from '../types'; + +// TODO read from feature flags +export const ALLOWED_BRIDGE_CHAIN_IDS = [ + CHAIN_IDS.MAINNET, + CHAIN_IDS.BSC, + CHAIN_IDS.POLYGON, + CHAIN_IDS.ZKSYNC_ERA, + CHAIN_IDS.AVALANCHE, + CHAIN_IDS.OPTIMISM, + CHAIN_IDS.ARBITRUM, + CHAIN_IDS.LINEA_MAINNET, + CHAIN_IDS.BASE, +]; + +export type AllowedBridgeChainIds = (typeof ALLOWED_BRIDGE_CHAIN_IDS)[number]; + +export const BRIDGE_DEV_API_BASE_URL = 'https://bridge.dev-api.cx.metamask.io'; +export const BRIDGE_PROD_API_BASE_URL = 'https://bridge.api.cx.metamask.io'; + +export enum BridgeClientId { + EXTENSION = 'extension', + MOBILE = 'mobile', +} + +export const ETH_USDT_ADDRESS = '0xdac17f958d2ee523a2206206994597c13d831ec7'; +export const METABRIDGE_ETHEREUM_ADDRESS = + '0x0439e60F02a8900a951603950d8D4527f400C3f1'; +export const BRIDGE_QUOTE_MAX_ETA_SECONDS = 60 * 60; // 1 hour +export const BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE = 0.5; // if a quote returns in x times less return than the best quote, ignore it + +export const BRIDGE_PREFERRED_GAS_ESTIMATE = 'high'; +export const BRIDGE_DEFAULT_SLIPPAGE = 0.5; +export const BRIDGE_MM_FEE_RATE = 0.875; +export const REFRESH_INTERVAL_MS = 30 * 1000; +export const DEFAULT_MAX_REFRESH_COUNT = 5; + +export const BRIDGE_CONTROLLER_NAME = 'BridgeController'; +export const DEFAULT_BRIDGE_CONTROLLER_STATE: BridgeControllerState = { + bridgeFeatureFlags: { + [BridgeFeatureFlagsKey.EXTENSION_CONFIG]: { + refreshRate: REFRESH_INTERVAL_MS, + maxRefreshCount: DEFAULT_MAX_REFRESH_COUNT, + support: false, + chains: {}, + }, + }, + quoteRequest: { + walletAddress: undefined, + srcTokenAddress: ZeroAddress, + slippage: BRIDGE_DEFAULT_SLIPPAGE, + }, + quotesInitialLoadTime: undefined, + quotes: [], + quotesLastFetched: undefined, + quotesLoadingStatus: undefined, + quoteFetchError: undefined, + quotesRefreshCount: 0, +}; + +export const METABRIDGE_CHAIN_TO_ADDRESS_MAP: Record = { + [CHAIN_IDS.MAINNET]: METABRIDGE_ETHEREUM_ADDRESS, +}; diff --git a/packages/bridge-controller/src/constants/chains.ts b/packages/bridge-controller/src/constants/chains.ts new file mode 100644 index 00000000000..abf24411276 --- /dev/null +++ b/packages/bridge-controller/src/constants/chains.ts @@ -0,0 +1,173 @@ +import type { AllowedBridgeChainIds } from './bridge'; + +/** + * An object containing all of the chain ids for networks both built in and + * those that we have added custom code to support our feature set. + */ +export const CHAIN_IDS = { + MAINNET: '0x1', + GOERLI: '0x5', + LOCALHOST: '0x539', + BSC: '0x38', + BSC_TESTNET: '0x61', + OPTIMISM: '0xa', + OPTIMISM_TESTNET: '0xaa37dc', + OPTIMISM_GOERLI: '0x1a4', + BASE: '0x2105', + BASE_TESTNET: '0x14a33', + OPBNB: '0xcc', + OPBNB_TESTNET: '0x15eb', + POLYGON: '0x89', + POLYGON_TESTNET: '0x13881', + AVALANCHE: '0xa86a', + AVALANCHE_TESTNET: '0xa869', + FANTOM: '0xfa', + FANTOM_TESTNET: '0xfa2', + CELO: '0xa4ec', + ARBITRUM: '0xa4b1', + HARMONY: '0x63564c40', + PALM: '0x2a15c308d', + SEPOLIA: '0xaa36a7', + HOLESKY: '0x4268', + LINEA_GOERLI: '0xe704', + LINEA_SEPOLIA: '0xe705', + AMOY: '0x13882', + BASE_SEPOLIA: '0x14a34', + BLAST_SEPOLIA: '0xa0c71fd', + OPTIMISM_SEPOLIA: '0xaa37dc', + PALM_TESTNET: '0x2a15c3083', + CELO_TESTNET: '0xaef3', + ZK_SYNC_ERA_TESTNET: '0x12c', + MANTA_SEPOLIA: '0x138b', + UNICHAIN_SEPOLIA: '0x515', + LINEA_MAINNET: '0xe708', + AURORA: '0x4e454152', + MOONBEAM: '0x504', + MOONBEAM_TESTNET: '0x507', + MOONRIVER: '0x505', + CRONOS: '0x19', + GNOSIS: '0x64', + ZKSYNC_ERA: '0x144', + TEST_ETH: '0x539', + ARBITRUM_GOERLI: '0x66eed', + BLAST: '0x13e31', + FILECOIN: '0x13a', + POLYGON_ZKEVM: '0x44d', + SCROLL: '0x82750', + SCROLL_SEPOLIA: '0x8274f', + WETHIO: '0x4e', + CHZ: '0x15b38', + NUMBERS: '0x290b', + SEI: '0x531', + APE_TESTNET: '0x8157', + APE_MAINNET: '0x8173', + BERACHAIN: '0x138d5', + METACHAIN_ONE: '0x1b6e6', + ARBITRUM_SEPOLIA: '0x66eee', + NEAR: '0x18d', + NEAR_TESTNET: '0x18e', + B3: '0x208d', + B3_TESTNET: '0x7c9', + GRAVITY_ALPHA_MAINNET: '0x659', + GRAVITY_ALPHA_TESTNET_SEPOLIA: '0x34c1', + LISK: '0x46f', + LISK_SEPOLIA: '0x106a', + INK_SEPOLIA: '0xba5eD', + INK: '0xdef1', + MODE_SEPOLIA: '0x397', + MODE: '0x868b', +} as const; + +export const NETWORK_TYPES = { + GOERLI: 'goerli', + LOCALHOST: 'localhost', + MAINNET: 'mainnet', + SEPOLIA: 'sepolia', + LINEA_GOERLI: 'linea-goerli', + LINEA_SEPOLIA: 'linea-sepolia', + LINEA_MAINNET: 'linea-mainnet', +} as const; + +export const MAINNET_DISPLAY_NAME = 'Ethereum Mainnet'; +export const GOERLI_DISPLAY_NAME = 'Goerli'; +export const SEPOLIA_DISPLAY_NAME = 'Sepolia'; +export const LINEA_GOERLI_DISPLAY_NAME = 'Linea Goerli'; +export const LINEA_SEPOLIA_DISPLAY_NAME = 'Linea Sepolia'; +export const LINEA_MAINNET_DISPLAY_NAME = 'Linea Mainnet'; +export const LOCALHOST_DISPLAY_NAME = 'Localhost 8545'; +export const BSC_DISPLAY_NAME = 'Binance Smart Chain'; +export const POLYGON_DISPLAY_NAME = 'Polygon'; +export const AVALANCHE_DISPLAY_NAME = 'Avalanche Network C-Chain'; +export const ARBITRUM_DISPLAY_NAME = 'Arbitrum One'; +export const BNB_DISPLAY_NAME = 'BNB Chain'; +export const OPTIMISM_DISPLAY_NAME = 'OP Mainnet'; +export const FANTOM_DISPLAY_NAME = 'Fantom Opera'; +export const HARMONY_DISPLAY_NAME = 'Harmony Mainnet Shard 0'; +export const PALM_DISPLAY_NAME = 'Palm'; +export const CELO_DISPLAY_NAME = 'Celo Mainnet'; +export const GNOSIS_DISPLAY_NAME = 'Gnosis'; +export const ZK_SYNC_ERA_DISPLAY_NAME = 'zkSync Era Mainnet'; +export const BASE_DISPLAY_NAME = 'Base Mainnet'; +export const AURORA_DISPLAY_NAME = 'Aurora Mainnet'; +export const CRONOS_DISPLAY_NAME = 'Cronos'; +export const POLYGON_ZKEVM_DISPLAY_NAME = 'Polygon zkEVM'; +export const MOONBEAM_DISPLAY_NAME = 'Moonbeam'; +export const MOONRIVER_DISPLAY_NAME = 'Moonriver'; +export const SCROLL_DISPLAY_NAME = 'Scroll'; +export const SCROLL_SEPOLIA_DISPLAY_NAME = 'Scroll Sepolia'; +export const OP_BNB_DISPLAY_NAME = 'opBNB'; +export const BERACHAIN_DISPLAY_NAME = 'Berachain Artio'; +export const METACHAIN_ONE_DISPLAY_NAME = 'Metachain One Mainnet'; +export const LISK_DISPLAY_NAME = 'Lisk'; +export const LISK_SEPOLIA_DISPLAY_NAME = 'Lisk Sepolia'; +export const INK_SEPOLIA_DISPLAY_NAME = 'Ink Sepolia'; +export const INK_DISPLAY_NAME = 'Ink Mainnet'; +export const SONEIUM_DISPLAY_NAME = 'Soneium Mainnet'; +export const MODE_SEPOLIA_DISPLAY_NAME = 'Mode Sepolia'; +export const MODE_DISPLAY_NAME = 'Mode Mainnet'; + +export const NETWORK_TO_NAME_MAP = { + [NETWORK_TYPES.GOERLI]: GOERLI_DISPLAY_NAME, + [NETWORK_TYPES.MAINNET]: MAINNET_DISPLAY_NAME, + [NETWORK_TYPES.LINEA_GOERLI]: LINEA_GOERLI_DISPLAY_NAME, + [NETWORK_TYPES.LINEA_SEPOLIA]: LINEA_SEPOLIA_DISPLAY_NAME, + [NETWORK_TYPES.LINEA_MAINNET]: LINEA_MAINNET_DISPLAY_NAME, + [NETWORK_TYPES.LOCALHOST]: LOCALHOST_DISPLAY_NAME, + [NETWORK_TYPES.SEPOLIA]: SEPOLIA_DISPLAY_NAME, + + [CHAIN_IDS.ARBITRUM]: ARBITRUM_DISPLAY_NAME, + [CHAIN_IDS.AVALANCHE]: AVALANCHE_DISPLAY_NAME, + [CHAIN_IDS.BSC]: BSC_DISPLAY_NAME, + [CHAIN_IDS.BASE]: BASE_DISPLAY_NAME, + [CHAIN_IDS.GOERLI]: GOERLI_DISPLAY_NAME, + [CHAIN_IDS.MAINNET]: MAINNET_DISPLAY_NAME, + [CHAIN_IDS.LINEA_GOERLI]: LINEA_GOERLI_DISPLAY_NAME, + [CHAIN_IDS.LINEA_MAINNET]: LINEA_MAINNET_DISPLAY_NAME, + [CHAIN_IDS.LINEA_SEPOLIA]: LINEA_SEPOLIA_DISPLAY_NAME, + [CHAIN_IDS.LOCALHOST]: LOCALHOST_DISPLAY_NAME, + [CHAIN_IDS.OPTIMISM]: OPTIMISM_DISPLAY_NAME, + [CHAIN_IDS.POLYGON]: POLYGON_DISPLAY_NAME, + [CHAIN_IDS.SCROLL]: SCROLL_DISPLAY_NAME, + [CHAIN_IDS.SCROLL_SEPOLIA]: SCROLL_SEPOLIA_DISPLAY_NAME, + [CHAIN_IDS.SEPOLIA]: SEPOLIA_DISPLAY_NAME, + [CHAIN_IDS.OPBNB]: OP_BNB_DISPLAY_NAME, + [CHAIN_IDS.ZKSYNC_ERA]: ZK_SYNC_ERA_DISPLAY_NAME, + [CHAIN_IDS.BERACHAIN]: BERACHAIN_DISPLAY_NAME, + [CHAIN_IDS.METACHAIN_ONE]: METACHAIN_ONE_DISPLAY_NAME, + [CHAIN_IDS.LISK]: LISK_DISPLAY_NAME, + [CHAIN_IDS.LISK_SEPOLIA]: LISK_SEPOLIA_DISPLAY_NAME, +} as const; +export const NETWORK_TO_SHORT_NETWORK_NAME_MAP: Record< + AllowedBridgeChainIds, + string +> = { + [CHAIN_IDS.MAINNET]: 'Ethereum', + [CHAIN_IDS.LINEA_MAINNET]: 'Linea', + [CHAIN_IDS.POLYGON]: NETWORK_TO_NAME_MAP[CHAIN_IDS.POLYGON], + [CHAIN_IDS.AVALANCHE]: 'Avalanche', + [CHAIN_IDS.BSC]: NETWORK_TO_NAME_MAP[CHAIN_IDS.BSC], + [CHAIN_IDS.ARBITRUM]: NETWORK_TO_NAME_MAP[CHAIN_IDS.ARBITRUM], + [CHAIN_IDS.OPTIMISM]: NETWORK_TO_NAME_MAP[CHAIN_IDS.OPTIMISM], + [CHAIN_IDS.ZKSYNC_ERA]: 'ZkSync Era', + [CHAIN_IDS.BASE]: 'Base', +}; diff --git a/packages/bridge-controller/src/constants/swaps.ts b/packages/bridge-controller/src/constants/swaps.ts new file mode 100644 index 00000000000..f226425bd17 --- /dev/null +++ b/packages/bridge-controller/src/constants/swaps.ts @@ -0,0 +1 @@ +export const SWAPS_API_V2_BASE_URL = 'https://swap.api.cx.metamask.io'; diff --git a/packages/bridge-controller/src/constants/tokens.ts b/packages/bridge-controller/src/constants/tokens.ts new file mode 100644 index 00000000000..be67ca8ccd8 --- /dev/null +++ b/packages/bridge-controller/src/constants/tokens.ts @@ -0,0 +1,144 @@ +import { CHAIN_IDS } from './chains'; + +export type SwapsTokenObject = { + /** + * The symbol of token object + */ + symbol: string; + /** + * The name for the network + */ + name: string; + /** + * An address that the metaswap-api recognizes as the default token + */ + address: string; + /** + * Number of digits after decimal point + */ + decimals: number; + /** + * URL for token icon + */ + iconUrl: string; +}; + +const DEFAULT_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000000'; + +export const CURRENCY_SYMBOLS = { + ARBITRUM: 'ETH', + AVALANCHE: 'AVAX', + BNB: 'BNB', + BUSD: 'BUSD', + CELO: 'CELO', + DAI: 'DAI', + GNOSIS: 'XDAI', + ETH: 'ETH', + FANTOM: 'FTM', + HARMONY: 'ONE', + PALM: 'PALM', + MATIC: 'MATIC', + POL: 'POL', + TEST_ETH: 'TESTETH', + USDC: 'USDC', + USDT: 'USDT', + WETH: 'WETH', + OPTIMISM: 'ETH', + CRONOS: 'CRO', + GLIMMER: 'GLMR', + MOONRIVER: 'MOVR', + ONE: 'ONE', +} as const; + +export const ETH_SWAPS_TOKEN_OBJECT: SwapsTokenObject = { + symbol: CURRENCY_SYMBOLS.ETH, + name: 'Ether', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +}; + +export const BNB_SWAPS_TOKEN_OBJECT: SwapsTokenObject = { + symbol: CURRENCY_SYMBOLS.BNB, + name: 'Binance Coin', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +} as const; + +export const MATIC_SWAPS_TOKEN_OBJECT: SwapsTokenObject = { + symbol: CURRENCY_SYMBOLS.POL, + name: 'Polygon', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +} as const; + +export const AVAX_SWAPS_TOKEN_OBJECT: SwapsTokenObject = { + symbol: CURRENCY_SYMBOLS.AVALANCHE, + name: 'Avalanche', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +} as const; + +export const TEST_ETH_SWAPS_TOKEN_OBJECT: SwapsTokenObject = { + symbol: CURRENCY_SYMBOLS.TEST_ETH, + name: 'Test Ether', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +} as const; + +export const GOERLI_SWAPS_TOKEN_OBJECT: SwapsTokenObject = { + symbol: CURRENCY_SYMBOLS.ETH, + name: 'Ether', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +} as const; + +export const SEPOLIA_SWAPS_TOKEN_OBJECT: SwapsTokenObject = { + symbol: CURRENCY_SYMBOLS.ETH, + name: 'Ether', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +} as const; + +export const ARBITRUM_SWAPS_TOKEN_OBJECT: SwapsTokenObject = { + ...ETH_SWAPS_TOKEN_OBJECT, +} as const; + +export const OPTIMISM_SWAPS_TOKEN_OBJECT: SwapsTokenObject = { + ...ETH_SWAPS_TOKEN_OBJECT, +} as const; + +export const ZKSYNC_ERA_SWAPS_TOKEN_OBJECT: SwapsTokenObject = { + ...ETH_SWAPS_TOKEN_OBJECT, +} as const; + +export const LINEA_SWAPS_TOKEN_OBJECT: SwapsTokenObject = { + ...ETH_SWAPS_TOKEN_OBJECT, +} as const; + +export const BASE_SWAPS_TOKEN_OBJECT: SwapsTokenObject = { + ...ETH_SWAPS_TOKEN_OBJECT, +} 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.BSC]: BNB_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.POLYGON]: MATIC_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.GOERLI]: GOERLI_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.SEPOLIA]: GOERLI_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.AVALANCHE]: AVAX_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.OPTIMISM]: OPTIMISM_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.ARBITRUM]: ARBITRUM_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.ZKSYNC_ERA]: ZKSYNC_ERA_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.LINEA_MAINNET]: LINEA_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.BASE]: BASE_SWAPS_TOKEN_OBJECT, +} as const; diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts new file mode 100644 index 00000000000..415682821fe --- /dev/null +++ b/packages/bridge-controller/src/index.ts @@ -0,0 +1,61 @@ +export { BridgeController } from './bridge-controller'; + +export type { + AssetType, + ChainConfiguration, + L1GasFees, + QuoteMetadata, + SortOrder, + BridgeToken, + BridgeFlag, + GasMultiplierByChainId, + FeatureFlagResponse, + BridgeAsset, + QuoteRequest, + Protocol, + ActionTypes, + Step, + RefuelData, + Quote, + QuoteResponse, + ChainId, + FeeType, + FeeData, + TxData, + BridgeFeatureFlagsKey, + BridgeFeatureFlags, + RequestStatus, + BridgeUserAction, + BridgeBackgroundAction, + BridgeControllerState, + BridgeControllerAction, + BridgeControllerActions, + BridgeControllerEvents, + BridgeControllerMessenger, +} from './types'; + +export { + ALLOWED_BRIDGE_CHAIN_IDS, + BridgeClientId, + BRIDGE_QUOTE_MAX_ETA_SECONDS, + BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE, + BRIDGE_PREFERRED_GAS_ESTIMATE, + BRIDGE_DEFAULT_SLIPPAGE, + BRIDGE_MM_FEE_RATE, + REFRESH_INTERVAL_MS, + DEFAULT_MAX_REFRESH_COUNT, + DEFAULT_BRIDGE_CONTROLLER_STATE, + METABRIDGE_CHAIN_TO_ADDRESS_MAP, +} from './constants/bridge'; + +export type { AllowedBridgeChainIds } from './constants/bridge'; + +export type { SwapsTokenObject } from './constants/tokens'; + +export { SWAPS_API_V2_BASE_URL } from './constants/swaps'; + +export { + getEthUsdtResetData, + isEthUsdt, + getBridgeApiBaseUrl, +} from './utils/bridge'; diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts new file mode 100644 index 00000000000..f79031316b2 --- /dev/null +++ b/packages/bridge-controller/src/types.ts @@ -0,0 +1,274 @@ +import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; +import type { + ControllerStateChangeEvent, + RestrictedMessenger, +} from '@metamask/base-controller'; +import type { + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetStateAction, + NetworkControllerGetNetworkClientByIdAction, +} from '@metamask/network-controller'; +import type { Hex } from '@metamask/utils'; +import type { BigNumber } from 'bignumber.js'; + +import type { BridgeController } from './bridge-controller'; +import type { BRIDGE_CONTROLLER_NAME } from './constants/bridge'; + +export type FetchFunction = ( + input: RequestInfo | URL, + init?: RequestInit, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +) => Promise; + +/** + * The types of assets that a user can send + * + */ +export enum AssetType { + /** The native asset for the current network, such as ETH */ + native = 'NATIVE', + /** An ERC20 token */ + token = 'TOKEN', + /** An ERC721 or ERC1155 token. */ + NFT = 'NFT', + /** + * A transaction interacting with a contract that isn't a token method + * interaction will be marked as dealing with an unknown asset type. + */ + unknown = 'UNKNOWN', +} + +export type ChainConfiguration = { + isActiveSrc: boolean; + isActiveDest: boolean; +}; + +export type L1GasFees = { + l1GasFeesInHexWei?: string; // l1 fees for approval and trade in hex wei, appended by controller +}; +// Values derived from the quote response +// valueInCurrency values are calculated based on the user's selected currency + +export type QuoteMetadata = { + gasFee: { amount: BigNumber; valueInCurrency: BigNumber | null }; + totalNetworkFee: { amount: BigNumber; valueInCurrency: BigNumber | null }; // estimatedGasFees + relayerFees + totalMaxNetworkFee: { amount: BigNumber; valueInCurrency: BigNumber | null }; // maxGasFees + relayerFees + toTokenAmount: { amount: BigNumber; valueInCurrency: BigNumber | null }; + adjustedReturn: { valueInCurrency: BigNumber | null }; // destTokenAmount - totalNetworkFee + sentAmount: { amount: BigNumber; valueInCurrency: BigNumber | null }; // srcTokenAmount + metabridgeFee + swapRate: BigNumber; // destTokenAmount / sentAmount + cost: { valueInCurrency: BigNumber | null }; // sentAmount - adjustedReturn +}; +// Sort order set by the user + +export enum SortOrder { + COST_ASC = 'cost_ascending', + ETA_ASC = 'time_descending', +} + +export type BridgeToken = { + type: AssetType.native | AssetType.token; + address: string; + symbol: string; + image: string; + decimals: number; + chainId: Hex; + balance: string; // raw balance + string: string | undefined; // normalized balance as a stringified number + tokenFiatAmount?: number | null; +} | null; +// Types copied from Metabridge API + +export enum BridgeFlag { + EXTENSION_CONFIG = 'extension-config', +} +type DecimalChainId = string; +export type GasMultiplierByChainId = Record; + +export type FeatureFlagResponse = { + [BridgeFlag.EXTENSION_CONFIG]: { + refreshRate: number; + maxRefreshCount: number; + support: boolean; + chains: Record; + }; +}; + +export type BridgeAsset = { + chainId: ChainId; + address: string; + symbol: string; + name: string; + decimals: number; + icon?: string; +}; + +export type QuoteRequest = { + walletAddress: string; + destWalletAddress?: string; + srcChainId: ChainId; + destChainId: ChainId; + srcTokenAddress: string; + destTokenAddress: string; + /** + * This is the amount sent, in atomic amount + */ + srcTokenAmount: string; + slippage: number; + aggIds?: string[]; + bridgeIds?: string[]; + insufficientBal?: boolean; + resetApproval?: boolean; + refuel?: boolean; +}; + +export type Protocol = { + name: string; + displayName?: string; + icon?: string; +}; + +export enum ActionTypes { + BRIDGE = 'bridge', + SWAP = 'swap', + REFUEL = 'refuel', +} + +export type Step = { + action: ActionTypes; + srcChainId: ChainId; + destChainId?: ChainId; + srcAsset: BridgeAsset; + destAsset: BridgeAsset; + srcAmount: string; + destAmount: string; + protocol: Protocol; +}; + +export type RefuelData = Step; + +export type Quote = { + requestId: string; + srcChainId: ChainId; + srcAsset: BridgeAsset; + // Some tokens have a fee of 0, so sometimes it's equal to amount sent + srcTokenAmount: string; // Atomic amount, the amount sent - fees + destChainId: ChainId; + destAsset: BridgeAsset; + destTokenAmount: string; // Atomic amount, the amount received + feeData: Record & + Partial>; + bridgeId: string; + bridges: string[]; + steps: Step[]; + refuel?: RefuelData; +}; + +export type QuoteResponse = { + quote: Quote; + approval: TxData | null; + trade: TxData; + estimatedProcessingTimeInSeconds: number; +}; + +export enum ChainId { + ETH = 1, + OPTIMISM = 10, + BSC = 56, + POLYGON = 137, + ZKSYNC = 324, + BASE = 8453, + ARBITRUM = 42161, + AVALANCHE = 43114, + LINEA = 59144, +} + +export enum FeeType { + METABRIDGE = 'metabridge', + REFUEL = 'refuel', +} +export type FeeData = { + amount: string; + asset: BridgeAsset; +}; +export type TxData = { + chainId: ChainId; + to: string; + from: string; + value: string; + data: string; + gasLimit: number | null; +}; +export enum BridgeFeatureFlagsKey { + EXTENSION_CONFIG = 'extensionConfig', +} + +export type BridgeFeatureFlags = { + [BridgeFeatureFlagsKey.EXTENSION_CONFIG]: { + refreshRate: number; + maxRefreshCount: number; + support: boolean; + chains: Record; + }; +}; +export enum RequestStatus { + LOADING, + FETCHED, + ERROR, +} +export enum BridgeUserAction { + SELECT_DEST_NETWORK = 'selectDestNetwork', + UPDATE_QUOTE_PARAMS = 'updateBridgeQuoteRequestParams', +} +export enum BridgeBackgroundAction { + SET_FEATURE_FLAGS = 'setBridgeFeatureFlags', + RESET_STATE = 'resetState', + GET_BRIDGE_ERC20_ALLOWANCE = 'getBridgeERC20Allowance', +} +export type BridgeControllerState = { + bridgeFeatureFlags: BridgeFeatureFlags; + quoteRequest: Partial; + quotes: (QuoteResponse & L1GasFees)[]; + quotesInitialLoadTime?: number; + quotesLastFetched?: number; + quotesLoadingStatus?: RequestStatus; + quoteFetchError?: string; + quotesRefreshCount: number; +}; + +export type BridgeControllerAction< + FunctionName extends keyof BridgeController, +> = { + type: `${typeof BRIDGE_CONTROLLER_NAME}:${FunctionName}`; + handler: BridgeController[FunctionName]; +}; + +// Maps to BridgeController function names +export type BridgeControllerActions = + | BridgeControllerAction + | BridgeControllerAction + | BridgeControllerAction + | BridgeControllerAction; + +export type BridgeControllerEvents = ControllerStateChangeEvent< + typeof BRIDGE_CONTROLLER_NAME, + BridgeControllerState +>; + +export type AllowedActions = + | AccountsControllerGetSelectedAccountAction + | NetworkControllerFindNetworkClientIdByChainIdAction + | NetworkControllerGetStateAction + | NetworkControllerGetNetworkClientByIdAction; +export type AllowedEvents = never; + +/** + * The messenger for the BridgeController. + */ +export type BridgeControllerMessenger = RestrictedMessenger< + typeof BRIDGE_CONTROLLER_NAME, + BridgeControllerActions | AllowedActions, + BridgeControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; diff --git a/packages/bridge-controller/src/utils/balance.test.ts b/packages/bridge-controller/src/utils/balance.test.ts new file mode 100644 index 00000000000..a8f4d6569f9 --- /dev/null +++ b/packages/bridge-controller/src/utils/balance.test.ts @@ -0,0 +1,249 @@ +import type { SafeEventEmitterProvider } from '@metamask/eth-json-rpc-provider'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import { ZeroAddress } from 'ethers'; +import { BrowserProvider, Contract } from 'ethers'; + +import * as balanceUtils from './balance'; +import { fetchTokenBalance } from './balance'; +import { FakeProvider } from '../../../../tests/fake-provider'; + +declare global { + // eslint-disable-next-line no-var + var ethereumProvider: SafeEventEmitterProvider; +} + +jest.mock('ethers', () => { + return { + ...jest.requireActual('ethers'), + Contract: jest.fn(), + BrowserProvider: jest.fn(), + }; +}); + +describe('balance', () => { + beforeEach(() => { + jest.clearAllMocks(); + global.ethereumProvider = new FakeProvider(); + }); + + describe('calcLatestSrcBalance', () => { + it('should return the ERC20 token balance', async () => { + const mockBalanceOf = jest.fn().mockResolvedValueOnce(BigInt(100)); + (Contract as unknown as jest.Mock).mockImplementation(() => ({ + balanceOf: mockBalanceOf, + })); + + expect( + await balanceUtils.calcLatestSrcBalance( + global.ethereumProvider, + '0x123', + '0x456', + '0x789', + ), + ).toStrictEqual(BigInt(100)); + expect(mockBalanceOf).toHaveBeenCalledTimes(1); + expect(mockBalanceOf).toHaveBeenCalledWith('0x123'); + }); + + it('should return the native asset balance', async () => { + const mockGetBalance = jest.fn().mockImplementation(() => { + return BigInt(100); + }); + (BrowserProvider as unknown as jest.Mock).mockImplementation(() => { + return { + getBalance: mockGetBalance, + }; + }); + + expect( + await balanceUtils.calcLatestSrcBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', + ZeroAddress, + '0x789', + ), + ).toStrictEqual(BigInt(100)); + expect(mockGetBalance).toHaveBeenCalledTimes(1); + expect(mockGetBalance).toHaveBeenCalledWith( + '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', + ); + }); + + it('should return undefined if token address and chainId are undefined', async () => { + const mockGetBalance = jest.fn(); + (BrowserProvider as unknown as jest.Mock).mockImplementation(() => { + return { + getBalance: mockGetBalance, + }; + }); + + const mockFetchTokenBalance = jest.spyOn( + balanceUtils, + 'fetchTokenBalance', + ); + expect( + await balanceUtils.calcLatestSrcBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', + undefined as never, + undefined as never, + ), + ).toBeUndefined(); + expect(mockFetchTokenBalance).not.toHaveBeenCalled(); + expect(mockGetBalance).not.toHaveBeenCalled(); + }); + }); + + describe('hasSufficientBalance', () => { + it('should return true if user has sufficient balance', async () => { + const mockGetBalance = jest.fn(); + (BrowserProvider as unknown as jest.Mock).mockImplementation(() => { + return { + getBalance: mockGetBalance, + }; + }); + + mockGetBalance.mockImplementation(() => { + return BigInt(10000000000000000000); + }); + + const mockBalanceOf = jest + .fn() + .mockResolvedValueOnce(BigInt('10000000000000000001')); + (Contract as unknown as jest.Mock).mockImplementation(() => ({ + balanceOf: mockBalanceOf, + })); + + expect( + await balanceUtils.hasSufficientBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + ZeroAddress, + '10000000000000000000', + '0x1', + ), + ).toBe(true); + + expect( + await balanceUtils.hasSufficientBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + '0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1', + '10000000000000000000', + '0x1', + ), + ).toBe(true); + }); + + it('should return false if user has native assets but insufficient ERC20 src tokens', async () => { + const mockGetBalance = jest.fn(); + (BrowserProvider as unknown as jest.Mock).mockImplementation(() => { + return { + getBalance: mockGetBalance, + }; + }); + + mockGetBalance.mockImplementation(() => { + return BigInt(10000000000000000000); + }); + const mockFetchTokenBalance = jest.spyOn( + balanceUtils, + 'fetchTokenBalance', + ); + mockFetchTokenBalance.mockResolvedValueOnce(BigInt(9000000000000000000)); + + expect( + await balanceUtils.hasSufficientBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + '0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1', + '10000000000000000000', + '0x1', + ), + ).toBe(false); + }); + + it('should return false if source token balance is undefined', async () => { + const mockBalanceOf = jest.fn().mockResolvedValueOnce(undefined); + (Contract as unknown as jest.Mock).mockImplementation(() => ({ + balanceOf: mockBalanceOf, + })); + + expect( + await balanceUtils.hasSufficientBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + '0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1', + '10000000000000000000', + '0x1', + ), + ).toBe(false); + + expect(mockBalanceOf).toHaveBeenCalledTimes(1); + expect(mockBalanceOf).toHaveBeenCalledWith( + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + ); + }); + }); +}); + +describe('fetchTokenBalance', () => { + let mockProvider: SafeEventEmitterProvider; + const mockAddress = '0x1234567890123456789012345678901234567890'; + const mockUserAddress = '0x9876543210987654321098765432109876543210'; + const mockBalance = BigInt(1000); + + beforeEach(() => { + jest.clearAllMocks(); + mockProvider = new FakeProvider(); + + // Mock BrowserProvider + (BrowserProvider as jest.Mock).mockImplementation(() => ({ + // Add any provider methods needed + })); + }); + + it('should fetch token balance when contract is valid', async () => { + // Mock Contract + const mockBalanceOf = jest.fn().mockResolvedValue(mockBalance); + (Contract as jest.Mock).mockImplementation(() => ({ + balanceOf: mockBalanceOf, + })); + + const result = await fetchTokenBalance( + mockAddress, + mockUserAddress, + mockProvider, + ); + + expect(BrowserProvider).toHaveBeenCalledWith(mockProvider); + expect(Contract).toHaveBeenCalledWith( + mockAddress, + abiERC20, + expect.anything(), + ); + expect(mockBalanceOf).toHaveBeenCalledWith(mockUserAddress); + expect(result).toBe(mockBalance); + }); + + it('should return undefined when contract is invalid', async () => { + // Mock Contract to return an object without balanceOf method + (Contract as jest.Mock).mockImplementation(() => ({ + // Empty object without balanceOf method + })); + + const result = await fetchTokenBalance( + mockAddress, + mockUserAddress, + mockProvider, + ); + + expect(BrowserProvider).toHaveBeenCalledWith(mockProvider); + expect(Contract).toHaveBeenCalledWith( + mockAddress, + abiERC20, + expect.anything(), + ); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/bridge-controller/src/utils/balance.ts b/packages/bridge-controller/src/utils/balance.ts new file mode 100644 index 00000000000..2788423f2df --- /dev/null +++ b/packages/bridge-controller/src/utils/balance.ts @@ -0,0 +1,51 @@ +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { Provider } from '@metamask/network-controller'; +import type { Hex } from '@metamask/utils'; +import { BrowserProvider, Contract, getAddress, ZeroAddress } from 'ethers'; + +export const fetchTokenBalance = async ( + address: string, + userAddress: string, + provider: Provider, +): Promise => { + const ethersProvider = new BrowserProvider(provider); + const tokenContract = new Contract(address, abiERC20, ethersProvider); + const tokenBalancePromise = + typeof tokenContract?.balanceOf === 'function' + ? tokenContract.balanceOf(userAddress) + : Promise.resolve(undefined); + return await tokenBalancePromise; +}; + +export const calcLatestSrcBalance = async ( + provider: Provider, + selectedAddress: string, + tokenAddress: string, + chainId: Hex, +): Promise => { + if (tokenAddress && chainId) { + if (tokenAddress === ZeroAddress) { + const ethersProvider = new BrowserProvider(provider); + return await ethersProvider.getBalance(getAddress(selectedAddress)); + } + return await fetchTokenBalance(tokenAddress, selectedAddress, provider); + } + return undefined; +}; + +export const hasSufficientBalance = async ( + provider: Provider, + selectedAddress: string, + tokenAddress: string, + fromTokenAmount: string, + chainId: Hex, +) => { + const srcTokenBalance = await calcLatestSrcBalance( + provider, + selectedAddress, + tokenAddress, + chainId, + ); + + return srcTokenBalance ? srcTokenBalance >= BigInt(fromTokenAmount) : false; +}; diff --git a/packages/bridge-controller/src/utils/bridge.test.ts b/packages/bridge-controller/src/utils/bridge.test.ts new file mode 100644 index 00000000000..013e9bf63eb --- /dev/null +++ b/packages/bridge-controller/src/utils/bridge.test.ts @@ -0,0 +1,170 @@ +/* eslint-disable n/no-process-env */ +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { Hex } from '@metamask/utils'; +import { Contract } from 'ethers'; + +import { + getEthUsdtResetData, + isEthUsdt, + isSwapsDefaultTokenAddress, + isSwapsDefaultTokenSymbol, + sumHexes, + getBridgeApiBaseUrl, +} from './bridge'; +import { + ETH_USDT_ADDRESS, + METABRIDGE_ETHEREUM_ADDRESS, +} from '../constants/bridge'; +import { + BRIDGE_DEV_API_BASE_URL, + BRIDGE_PROD_API_BASE_URL, +} from '../constants/bridge'; +import { CHAIN_IDS } from '../constants/chains'; +import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP } from '../constants/tokens'; + +describe('Bridge utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('sumHexes', () => { + it('returns 0x0 for empty input', () => { + expect(sumHexes()).toBe('0x0'); + }); + + it('returns same value for single input', () => { + expect(sumHexes('0xff')).toBe('0xff'); + expect(sumHexes('0x0')).toBe('0x0'); + expect(sumHexes('0x1')).toBe('0x1'); + }); + + it('correctly sums two hex values', () => { + expect(sumHexes('0x1', '0x1')).toBe('0x2'); + expect(sumHexes('0xff', '0x1')).toBe('0x100'); + expect(sumHexes('0x0', '0xff')).toBe('0xff'); + }); + + it('correctly sums multiple hex values', () => { + expect(sumHexes('0x1', '0x2', '0x3')).toBe('0x6'); + expect(sumHexes('0xff', '0xff', '0x2')).toBe('0x200'); + expect(sumHexes('0x0', '0x0', '0x0')).toBe('0x0'); + }); + + it('handles large numbers', () => { + expect(sumHexes('0xffffffff', '0x1')).toBe('0x100000000'); + expect(sumHexes('0xffffffff', '0xffffffff')).toBe('0x1fffffffe'); + }); + + it('throws for invalid hex strings', () => { + expect(() => sumHexes('0xg')).toThrow('Cannot convert 0xg to a BigInt'); + }); + }); + + 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); + expect(isEthUsdt(CHAIN_IDS.MAINNET, ETH_USDT_ADDRESS.toUpperCase())).toBe( + true, + ); + }); + + it('returns false for non-mainnet chain', () => { + expect(isEthUsdt(CHAIN_IDS.GOERLI, ETH_USDT_ADDRESS)).toBe(false); + }); + + it('returns false for different address on mainnet', () => { + expect(isEthUsdt(CHAIN_IDS.MAINNET, METABRIDGE_ETHEREUM_ADDRESS)).toBe( + false, + ); + }); + }); + + describe('isSwapsDefaultTokenAddress', () => { + it('returns true for default token address of given chain', () => { + const chainId = Object.keys(SWAPS_CHAINID_DEFAULT_TOKEN_MAP)[0] as Hex; + const defaultToken = + SWAPS_CHAINID_DEFAULT_TOKEN_MAP[ + chainId as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP + ]; + + expect(isSwapsDefaultTokenAddress(defaultToken.address, chainId)).toBe( + true, + ); + }); + + it('returns false for non-default token address', () => { + const chainId = Object.keys(SWAPS_CHAINID_DEFAULT_TOKEN_MAP)[0] as Hex; + expect(isSwapsDefaultTokenAddress('0x1234', chainId)).toBe(false); + }); + + it('returns false for invalid inputs', () => { + const chainId = Object.keys(SWAPS_CHAINID_DEFAULT_TOKEN_MAP)[0] as Hex; + expect(isSwapsDefaultTokenAddress('', chainId)).toBe(false); + expect(isSwapsDefaultTokenAddress('0x1234', '' as Hex)).toBe(false); + }); + }); + + describe('isSwapsDefaultTokenSymbol', () => { + it('returns true for default token symbol of given chain', () => { + const chainId = Object.keys(SWAPS_CHAINID_DEFAULT_TOKEN_MAP)[0] as Hex; + const defaultToken = + SWAPS_CHAINID_DEFAULT_TOKEN_MAP[ + chainId as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP + ]; + + expect(isSwapsDefaultTokenSymbol(defaultToken.symbol, chainId)).toBe( + true, + ); + }); + + it('returns false for non-default token symbol', () => { + const chainId = Object.keys(SWAPS_CHAINID_DEFAULT_TOKEN_MAP)[0] as Hex; + expect(isSwapsDefaultTokenSymbol('FAKE', chainId)).toBe(false); + }); + + it('returns false for invalid inputs', () => { + const chainId = Object.keys(SWAPS_CHAINID_DEFAULT_TOKEN_MAP)[0] as Hex; + expect(isSwapsDefaultTokenSymbol('', chainId)).toBe(false); + expect(isSwapsDefaultTokenSymbol('ETH', '' as Hex)).toBe(false); + }); + }); + + describe('getBridgeApiBaseUrl', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('returns custom API URL when BRIDGE_CUSTOM_API_BASE_URL is set', () => { + process.env.BRIDGE_CUSTOM_API_BASE_URL = 'https://custom-api.example.com'; + expect(getBridgeApiBaseUrl()).toBe('https://custom-api.example.com'); + }); + + it('returns dev API URL when BRIDGE_USE_DEV_APIS is set', () => { + process.env.BRIDGE_USE_DEV_APIS = 'true'; + expect(getBridgeApiBaseUrl()).toBe(BRIDGE_DEV_API_BASE_URL); + }); + + it('returns prod API URL by default', () => { + expect(getBridgeApiBaseUrl()).toBe(BRIDGE_PROD_API_BASE_URL); + }); + }); +}); diff --git a/packages/bridge-controller/src/utils/bridge.ts b/packages/bridge-controller/src/utils/bridge.ts new file mode 100644 index 00000000000..b152c84ef09 --- /dev/null +++ b/packages/bridge-controller/src/utils/bridge.ts @@ -0,0 +1,101 @@ +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { Hex } from '@metamask/utils'; +import { Contract } from 'ethers'; + +import { + DEFAULT_BRIDGE_CONTROLLER_STATE, + BRIDGE_DEV_API_BASE_URL, + BRIDGE_PROD_API_BASE_URL, + ETH_USDT_ADDRESS, + METABRIDGE_ETHEREUM_ADDRESS, +} from '../constants/bridge'; +import { CHAIN_IDS } from '../constants/chains'; +import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP } from '../constants/tokens'; +import type { BridgeControllerState } from '../types'; + +export const getDefaultBridgeControllerState = (): BridgeControllerState => { + return DEFAULT_BRIDGE_CONTROLLER_STATE; +}; + +export const getBridgeApiBaseUrl = () => { + if (process.env.BRIDGE_CUSTOM_API_BASE_URL) { + return process.env.BRIDGE_CUSTOM_API_BASE_URL; + } + + if (process.env.BRIDGE_USE_DEV_APIS) { + return BRIDGE_DEV_API_BASE_URL; + } + + return BRIDGE_PROD_API_BASE_URL; +}; +/** + * A function to return the txParam data for setting allowance to 0 for USDT on Ethereum + * + * @returns The txParam data that will reset allowance to 0, combine it with the approval tx params received from Bridge API + */ + +export const getEthUsdtResetData = () => { + const UsdtContractInterface = new Contract(ETH_USDT_ADDRESS, abiERC20) + .interface; + const data = UsdtContractInterface.encodeFunctionData('approve', [ + METABRIDGE_ETHEREUM_ADDRESS, + '0', + ]); + + return data; +}; + +export const isEthUsdt = (chainId: Hex, address: string) => + chainId === CHAIN_IDS.MAINNET && + address.toLowerCase() === ETH_USDT_ADDRESS.toLowerCase(); + +export const sumHexes = (...hexStrings: string[]): Hex => { + if (hexStrings.length === 0) { + return '0x0'; + } + + const sum = hexStrings.reduce((acc, hex) => acc + BigInt(hex), BigInt(0)); + return `0x${sum.toString(16)}`; +}; +/** + * Checks whether the provided address is strictly equal to the address for + * the default swaps token of the provided chain. + * + * @param address - The string to compare to the default token address + * @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 + */ + +export const isSwapsDefaultTokenAddress = (address: string, chainId: Hex) => { + if (!address || !chainId) { + return false; + } + + return ( + address === + SWAPS_CHAINID_DEFAULT_TOKEN_MAP[ + chainId as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP + ]?.address + ); +}; +/** + * Checks whether the provided symbol is strictly equal to the symbol for + * the default swaps token of the provided chain. + * + * @param symbol - The string to compare to the default token symbol + * @param chainId - The hex encoded chain ID of the default swaps token to check + * @returns Whether the symbol is the provided chain's default token symbol + */ + +export const isSwapsDefaultTokenSymbol = (symbol: string, chainId: Hex) => { + if (!symbol || !chainId) { + return false; + } + + return ( + symbol === + SWAPS_CHAINID_DEFAULT_TOKEN_MAP[ + chainId as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP + ]?.symbol + ); +}; diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts new file mode 100644 index 00000000000..a287e15af4b --- /dev/null +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -0,0 +1,350 @@ +import { ZeroAddress } from 'ethers'; + +import { + fetchBridgeFeatureFlags, + fetchBridgeQuotes, + fetchBridgeTokens, +} from './fetch'; +import mockBridgeQuotesErc20Erc20 from '../../tests/mock-quotes-erc20-erc20.json'; +import mockBridgeQuotesNativeErc20 from '../../tests/mock-quotes-native-erc20.json'; +import { BridgeClientId } from '../constants/bridge'; +import { CHAIN_IDS } from '../constants/chains'; + +const mockFetchFn = jest.fn(); + +describe('Bridge utils', () => { + describe('fetchBridgeFeatureFlags', () => { + it('should fetch bridge feature flags successfully', async () => { + const mockResponse = { + 'extension-config': { + refreshRate: 3, + maxRefreshCount: 1, + support: true, + chains: { + '1': { + isActiveSrc: true, + isActiveDest: true, + }, + '10': { + isActiveSrc: true, + isActiveDest: false, + }, + '59144': { + isActiveSrc: true, + isActiveDest: true, + }, + '120': { + isActiveSrc: true, + isActiveDest: false, + }, + '137': { + isActiveSrc: false, + isActiveDest: true, + }, + '11111': { + isActiveSrc: false, + isActiveDest: true, + }, + }, + }, + }; + + mockFetchFn.mockResolvedValue(mockResponse); + + const result = await fetchBridgeFeatureFlags( + BridgeClientId.EXTENSION, + mockFetchFn, + ); + + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/getAllFeatureFlags', + { + headers: { 'X-Client-Id': 'extension' }, + }, + ); + + expect(result).toStrictEqual({ + extensionConfig: { + maxRefreshCount: 1, + refreshRate: 3, + support: true, + chains: { + [CHAIN_IDS.MAINNET]: { + isActiveSrc: true, + isActiveDest: true, + }, + [CHAIN_IDS.OPTIMISM]: { + isActiveSrc: true, + isActiveDest: false, + }, + [CHAIN_IDS.LINEA_MAINNET]: { + isActiveSrc: true, + isActiveDest: true, + }, + '0x78': { + isActiveSrc: true, + isActiveDest: false, + }, + [CHAIN_IDS.POLYGON]: { + isActiveSrc: false, + isActiveDest: true, + }, + '0x2b67': { + isActiveSrc: false, + isActiveDest: true, + }, + }, + }, + }); + }); + + it('should use fallback bridge feature flags if response is unexpected', async () => { + const mockResponse = { + 'extension-config': { + refreshRate: 3, + maxRefreshCount: 1, + support: 25, + chains: { + a: { + isActiveSrc: 1, + isActiveDest: 'test', + }, + '2': { + isActiveSrc: 'test', + isActiveDest: 2, + }, + }, + }, + }; + + mockFetchFn.mockResolvedValue(mockResponse); + + const result = await fetchBridgeFeatureFlags( + BridgeClientId.EXTENSION, + mockFetchFn, + ); + + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/getAllFeatureFlags', + { + headers: { 'X-Client-Id': 'extension' }, + }, + ); + + expect(result).toStrictEqual({ + extensionConfig: { + maxRefreshCount: 5, + refreshRate: 30000, + support: false, + chains: {}, + }, + }); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('Failed to fetch'); + + mockFetchFn.mockRejectedValue(mockError); + + await expect( + fetchBridgeFeatureFlags(BridgeClientId.EXTENSION, mockFetchFn), + ).rejects.toThrow(mockError); + }); + }); + + describe('fetchBridgeTokens', () => { + it('should fetch bridge tokens successfully', async () => { + const mockResponse = [ + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + symbol: 'ABC', + decimals: 16, + }, + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f985', + decimals: 16, + }, + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f986', + decimals: 16, + symbol: 'DEF', + aggregators: ['lifi'], + }, + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f987', + symbol: 'DEF', + }, + { + address: '0x124', + symbol: 'JKL', + decimals: 16, + }, + ]; + + mockFetchFn.mockResolvedValue(mockResponse); + + const result = await fetchBridgeTokens( + '0xa', + BridgeClientId.EXTENSION, + mockFetchFn, + ); + + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/getTokens?chainId=10', + { + headers: { 'X-Client-Id': 'extension' }, + }, + ); + + expect(result).toStrictEqual({ + '0x0000000000000000000000000000000000000000': { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + iconUrl: '', + name: 'Ether', + symbol: 'ETH', + }, + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f986': { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f986', + decimals: 16, + symbol: 'DEF', + aggregators: ['lifi'], + }, + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + decimals: 16, + symbol: 'ABC', + }, + }); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('Failed to fetch'); + + mockFetchFn.mockRejectedValue(mockError); + + await expect( + fetchBridgeTokens('0xa', BridgeClientId.EXTENSION, mockFetchFn), + ).rejects.toThrow(mockError); + }); + }); + + describe('fetchBridgeQuotes', () => { + it('should fetch bridge quotes successfully, no approvals', async () => { + mockFetchFn.mockResolvedValue(mockBridgeQuotesNativeErc20); + const { signal } = new AbortController(); + + const result = await fetchBridgeQuotes( + { + walletAddress: '0x123', + srcChainId: 1, + destChainId: 10, + srcTokenAddress: ZeroAddress, + destTokenAddress: ZeroAddress, + srcTokenAmount: '20000', + slippage: 0.5, + }, + signal, + BridgeClientId.EXTENSION, + mockFetchFn, + ); + + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x123&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&slippage=0.5&insufficientBal=false&resetApproval=false', + { + headers: { 'X-Client-Id': 'extension' }, + signal, + }, + ); + + expect(result).toStrictEqual(mockBridgeQuotesNativeErc20); + }); + + it('should fetch bridge quotes successfully, with approvals', async () => { + mockFetchFn.mockResolvedValue([ + ...mockBridgeQuotesErc20Erc20, + { ...mockBridgeQuotesErc20Erc20[0], approval: null }, + { ...mockBridgeQuotesErc20Erc20[0], trade: null }, + ]); + const { signal } = new AbortController(); + + const result = await fetchBridgeQuotes( + { + walletAddress: '0x123', + srcChainId: 1, + destChainId: 10, + srcTokenAddress: ZeroAddress, + destTokenAddress: ZeroAddress, + srcTokenAmount: '20000', + slippage: 0.5, + }, + signal, + BridgeClientId.EXTENSION, + mockFetchFn, + ); + + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x123&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&slippage=0.5&insufficientBal=false&resetApproval=false', + { + headers: { 'X-Client-Id': 'extension' }, + signal, + }, + ); + + expect(result).toStrictEqual(mockBridgeQuotesErc20Erc20); + }); + + it('should filter out malformed bridge quotes', async () => { + mockFetchFn.mockResolvedValue([ + ...mockBridgeQuotesErc20Erc20, + ...mockBridgeQuotesErc20Erc20.map( + ({ quote, ...restOfQuote }) => restOfQuote, + ), + { + ...mockBridgeQuotesErc20Erc20[0], + quote: { + srcAsset: { + ...mockBridgeQuotesErc20Erc20[0].quote.srcAsset, + decimals: undefined, + }, + }, + }, + { + ...mockBridgeQuotesErc20Erc20[1], + quote: { + srcAsset: { + ...mockBridgeQuotesErc20Erc20[1].quote.destAsset, + address: undefined, + }, + }, + }, + ]); + const { signal } = new AbortController(); + + const result = await fetchBridgeQuotes( + { + walletAddress: '0x123', + srcChainId: 1, + destChainId: 10, + srcTokenAddress: ZeroAddress, + destTokenAddress: ZeroAddress, + srcTokenAmount: '20000', + slippage: 0.5, + }, + signal, + BridgeClientId.EXTENSION, + mockFetchFn, + ); + + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x123&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&slippage=0.5&insufficientBal=false&resetApproval=false', + { + headers: { 'X-Client-Id': 'extension' }, + signal, + }, + ); + + expect(result).toStrictEqual(mockBridgeQuotesErc20Erc20); + }); + }); +}); diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts new file mode 100644 index 00000000000..674b7bcf452 --- /dev/null +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -0,0 +1,201 @@ +import type { Hex } from '@metamask/utils'; +import { hexToNumber, numberToHex } from '@metamask/utils'; + +import { + isSwapsDefaultTokenAddress, + isSwapsDefaultTokenSymbol, + getBridgeApiBaseUrl, +} from './bridge'; +import { + FEATURE_FLAG_VALIDATORS, + QUOTE_VALIDATORS, + TX_DATA_VALIDATORS, + TOKEN_VALIDATORS, + validateResponse, + QUOTE_RESPONSE_VALIDATORS, + FEE_DATA_VALIDATORS, +} from './validators'; +import { REFRESH_INTERVAL_MS } from '../constants/bridge'; +import type { SwapsTokenObject } from '../constants/tokens'; +import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP } from '../constants/tokens'; +import type { + FeatureFlagResponse, + FeeData, + Quote, + QuoteRequest, + QuoteResponse, + TxData, + BridgeFeatureFlags, + FetchFunction, +} from '../types'; +import { BridgeFlag, FeeType, BridgeFeatureFlagsKey } from '../types'; + +// TODO put this back in once we have a fetchWithCache equivalent +// const CACHE_REFRESH_TEN_MINUTES = 10 * Duration.Minute; + +export const getClientIdHeader = (clientId: string) => ({ + 'X-Client-Id': clientId, +}); + +/** + * Fetches the bridge feature flags + * + * @param clientId - The client ID for metrics + * @param fetchFn - The fetch function to use + * @returns The bridge feature flags + */ +export async function fetchBridgeFeatureFlags( + clientId: string, + fetchFn: FetchFunction, +): Promise { + const url = `${getBridgeApiBaseUrl()}/getAllFeatureFlags`; + const rawFeatureFlags = await fetchFn(url, { + headers: getClientIdHeader(clientId), + }); + + if ( + validateResponse( + FEATURE_FLAG_VALIDATORS, + rawFeatureFlags, + url, + ) + ) { + return { + [BridgeFeatureFlagsKey.EXTENSION_CONFIG]: { + ...rawFeatureFlags[BridgeFlag.EXTENSION_CONFIG], + chains: Object.entries( + rawFeatureFlags[BridgeFlag.EXTENSION_CONFIG].chains, + ).reduce( + (acc, [chainId, value]) => ({ + ...acc, + [numberToHex(Number(chainId))]: value, + }), + {}, + ), + }, + }; + } + + return { + [BridgeFeatureFlagsKey.EXTENSION_CONFIG]: { + refreshRate: REFRESH_INTERVAL_MS, + maxRefreshCount: 5, + support: false, + chains: {}, + }, + }; +} + +/** + * Returns a list of enabled (unblocked) tokens + * + * @param chainId - The chain ID to fetch tokens for + * @param clientId - The client ID for metrics + * @param fetchFn - The fetch function to use + * @returns A list of enabled (unblocked) tokens + */ +export async function fetchBridgeTokens( + chainId: Hex, + clientId: string, + fetchFn: FetchFunction, +): Promise> { + // TODO make token api v2 call + const url = `${getBridgeApiBaseUrl()}/getTokens?chainId=${hexToNumber( + chainId, + )}`; + + // TODO we will need to cache these. In Extension fetchWithCache is used. This is due to the following: + // If we allow selecting dest networks which the user has not imported, + // note that the Assets controller won't be able to provide tokens. In extension we fetch+cache the token list from bridge-api to handle this + const tokens = await fetchFn(url, { + headers: getClientIdHeader(clientId), + }); + + const nativeToken = + SWAPS_CHAINID_DEFAULT_TOKEN_MAP[ + chainId as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP + ]; + + const transformedTokens: Record = {}; + if (nativeToken) { + transformedTokens[nativeToken.address] = nativeToken; + } + + tokens.forEach((token: unknown) => { + if ( + validateResponse(TOKEN_VALIDATORS, token, url, false) && + !( + isSwapsDefaultTokenSymbol(token.symbol, chainId) || + isSwapsDefaultTokenAddress(token.address, chainId) + ) + ) { + transformedTokens[token.address] = token; + } + }); + return transformedTokens; +} + +// Returns a list of bridge tx quotes +/** + * + * @param request - The quote request + * @param signal - The abort signal + * @param clientId - The client ID for metrics + * @param fetchFn - The fetch function to use + * @returns A list of bridge tx quotes + */ +export async function fetchBridgeQuotes( + request: QuoteRequest, + signal: AbortSignal, + clientId: string, + fetchFn: FetchFunction, +): Promise { + const queryParams = new URLSearchParams({ + walletAddress: request.walletAddress, + srcChainId: request.srcChainId.toString(), + destChainId: request.destChainId.toString(), + srcTokenAddress: request.srcTokenAddress, + destTokenAddress: request.destTokenAddress, + srcTokenAmount: request.srcTokenAmount, + slippage: request.slippage.toString(), + insufficientBal: request.insufficientBal ? 'true' : 'false', + resetApproval: request.resetApproval ? 'true' : 'false', + }); + const url = `${getBridgeApiBaseUrl()}/getQuote?${queryParams}`; + const quotes = await fetchFn(url, { + headers: getClientIdHeader(clientId), + signal, + }); + + const filteredQuotes = quotes.filter((quoteResponse: QuoteResponse) => { + const { quote, approval, trade } = quoteResponse; + return ( + validateResponse( + QUOTE_RESPONSE_VALIDATORS, + quoteResponse, + url, + ) && + validateResponse(QUOTE_VALIDATORS, quote, url) && + validateResponse( + TOKEN_VALIDATORS, + quote.srcAsset, + url, + ) && + validateResponse( + TOKEN_VALIDATORS, + quote.destAsset, + url, + ) && + validateResponse(TX_DATA_VALIDATORS, trade, url) && + validateResponse( + FEE_DATA_VALIDATORS, + quote.feeData[FeeType.METABRIDGE], + url, + ) && + (approval + ? validateResponse(TX_DATA_VALIDATORS, approval, url) + : true) + ); + }); + return filteredQuotes; +} diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts new file mode 100644 index 00000000000..8ea616fd345 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote.ts @@ -0,0 +1,36 @@ +import type { QuoteRequest } from '../types'; + +export const isValidQuoteRequest = ( + partialRequest: Partial, + requireAmount = true, +): partialRequest is QuoteRequest => { + const stringFields = ['srcTokenAddress', 'destTokenAddress']; + if (requireAmount) { + stringFields.push('srcTokenAmount'); + } + const numberFields = ['srcChainId', 'destChainId', 'slippage']; + + return ( + stringFields.every( + (field) => + field in partialRequest && + typeof partialRequest[field as keyof typeof partialRequest] === + 'string' && + partialRequest[field as keyof typeof partialRequest] !== undefined && + partialRequest[field as keyof typeof partialRequest] !== '' && + partialRequest[field as keyof typeof partialRequest] !== null, + ) && + numberFields.every( + (field) => + field in partialRequest && + typeof partialRequest[field as keyof typeof partialRequest] === + 'number' && + partialRequest[field as keyof typeof partialRequest] !== undefined && + !isNaN(Number(partialRequest[field as keyof typeof partialRequest])) && + partialRequest[field as keyof typeof partialRequest] !== null, + ) && + (requireAmount + ? Boolean((partialRequest.srcTokenAmount ?? '').match(/^[1-9]\d*$/u)) + : true) + ); +}; diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts new file mode 100644 index 00000000000..56d8f93a47a --- /dev/null +++ b/packages/bridge-controller/src/utils/validators.ts @@ -0,0 +1,162 @@ +import { isValidHexAddress as isValidHexAddress_ } from '@metamask/controller-utils'; +import { isStrictHexString } from '@metamask/utils'; + +import type { SwapsTokenObject } from '../constants/tokens'; +import type { + FeatureFlagResponse, + FeeData, + Quote, + QuoteResponse, + TxData, +} from '../types'; +import { BridgeFlag } from '../types'; + +export const truthyString = (string: string) => Boolean(string?.length); +export const truthyDigitString = (string: string) => + truthyString(string) && Boolean(string.match(/^\d+$/u)); + +export const isValidNumber = (v: unknown): v is number => typeof v === 'number'; +const isValidObject = (v: unknown): v is object => + typeof v === 'object' && v !== null; +const isValidString = (v: unknown): v is string => + typeof v === 'string' && v.length > 0; +const isValidHexAddress = (v: unknown) => + isValidString(v) && isValidHexAddress_(v, { allowNonPrefixed: false }); + +type Validator = { + property: keyof ExpectedResponse; + type: string; + validator?: (value: unknown) => boolean; +}; + +export const validateData = ( + validators: Validator[], + object: unknown, + urlUsed: string, + logError = true, +): object is ExpectedResponse => { + return validators.every(({ property, type, validator }) => { + const types = type.split('|'); + const propertyString = String(property); + + const valid = + isValidObject(object) && + types.some( + (_type) => + typeof object[propertyString as keyof typeof object] === _type, + ) && + (!validator || validator(object[propertyString as keyof typeof object])); + + if (!valid && logError) { + const value = isValidObject(object) + ? object[propertyString as keyof typeof object] + : undefined; + const typeString = isValidObject(object) + ? typeof object[propertyString as keyof typeof object] + : 'undefined'; + + console.error( + `response to GET ${urlUsed} invalid for property ${String(property)}; value was:`, + value, + '| type was: ', + typeString, + ); + } + return valid; + }); +}; + +export const validateResponse = ( + validators: Validator[], + data: unknown, + urlUsed: string, + logError = true, +): data is ExpectedResponse => { + return validateData(validators, data, urlUsed, logError); +}; + +export const FEATURE_FLAG_VALIDATORS = [ + { + property: BridgeFlag.EXTENSION_CONFIG, + type: 'object', + validator: ( + v: unknown, + ): v is Pick => + isValidObject(v) && + 'refreshRate' in v && + isValidNumber(v.refreshRate) && + 'maxRefreshCount' in v && + isValidNumber(v.maxRefreshCount) && + 'chains' in v && + isValidObject(v.chains) && + Object.values(v.chains).every((chain) => isValidObject(chain)) && + Object.values(v.chains).every( + (chain) => + 'isActiveSrc' in chain && + 'isActiveDest' in chain && + typeof chain.isActiveSrc === 'boolean' && + typeof chain.isActiveDest === 'boolean', + ), + }, +]; + +export const TOKEN_AGGREGATOR_VALIDATORS = [ + { + property: 'aggregators', + type: 'object', + validator: (v: unknown): v is number[] => + isValidObject(v) && Object.values(v).every(isValidString), + }, +]; + +export const TOKEN_VALIDATORS: Validator[] = [ + { property: 'decimals', type: 'number' }, + { property: 'address', type: 'string', validator: isValidHexAddress }, + { + property: 'symbol', + type: 'string', + validator: (v: unknown) => isValidString(v) && v.length <= 12, + }, +]; + +export const QUOTE_RESPONSE_VALIDATORS: Validator[] = [ + { property: 'quote', type: 'object', validator: isValidObject }, + { property: 'estimatedProcessingTimeInSeconds', type: 'number' }, + { + property: 'approval', + type: 'object|undefined', + validator: (v: unknown) => v === undefined || isValidObject(v), + }, + { property: 'trade', type: 'object', validator: isValidObject }, +]; + +export const QUOTE_VALIDATORS: Validator[] = [ + { property: 'requestId', type: 'string' }, + { property: 'srcTokenAmount', type: 'string' }, + { property: 'destTokenAmount', type: 'string' }, + { property: 'bridgeId', type: 'string' }, + { property: 'bridges', type: 'object', validator: isValidObject }, + { property: 'srcChainId', type: 'number' }, + { property: 'destChainId', type: 'number' }, + { property: 'srcAsset', type: 'object', validator: isValidObject }, + { property: 'destAsset', type: 'object', validator: isValidObject }, + { property: 'feeData', type: 'object', validator: isValidObject }, +]; + +export const FEE_DATA_VALIDATORS: Validator[] = [ + { + property: 'amount', + type: 'string', + validator: (v: unknown) => truthyDigitString(String(v)), + }, + { property: 'asset', type: 'object', validator: isValidObject }, +]; + +export const TX_DATA_VALIDATORS: Validator[] = [ + { property: 'chainId', type: 'number' }, + { property: 'value', type: 'string', validator: isStrictHexString }, + { property: 'gasLimit', type: 'number' }, + { property: 'to', type: 'string', validator: isValidHexAddress }, + { property: 'from', type: 'string', validator: isValidHexAddress }, + { property: 'data', type: 'string', validator: isStrictHexString }, +]; diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-erc20.json b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.json new file mode 100644 index 00000000000..8b589aa85e1 --- /dev/null +++ b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.json @@ -0,0 +1,248 @@ +[ + { + "quote": { + "requestId": "90ae8e69-f03a-4cf6-bab7-ed4e3431eb37", + "srcChainId": 10, + "srcAsset": { + "chainId": 10, + "address": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6, + "icon": "https://media.socket.tech/tokens/all/USDC", + "logoURI": "https://media.socket.tech/tokens/all/USDC", + "chainAgnosticId": null + }, + "srcTokenAmount": "14000000", + "destChainId": 137, + "destAsset": { + "chainId": 137, + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "symbol": "USDC", + "name": "Native USD Coin (POS)", + "decimals": 6, + "icon": "https://media.socket.tech/tokens/all/USDC", + "logoURI": "https://media.socket.tech/tokens/all/USDC", + "chainAgnosticId": "USDC" + }, + "destTokenAmount": "13984280", + "feeData": { + "metabridge": { + "amount": "0", + "asset": { + "chainId": 10, + "address": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6, + "icon": "https://media.socket.tech/tokens/all/USDC", + "logoURI": "https://media.socket.tech/tokens/all/USDC", + "chainAgnosticId": null + } + } + }, + "bridgeId": "socket", + "bridges": ["across"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 137, + "protocol": { + "name": "across", + "displayName": "Across", + "icon": "https://miro.medium.com/max/800/1*PN_F5yW4VMBgs_xX-fsyzQ.png" + }, + "srcAsset": { + "chainId": 10, + "address": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6, + "icon": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "logoURI": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "chainAgnosticId": null + }, + "destAsset": { + "chainId": 137, + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "symbol": "USDC", + "name": "Native USD Coin (POS)", + "decimals": 6, + "icon": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "logoURI": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "chainAgnosticId": "USDC" + }, + "srcAmount": "14000000", + "destAmount": "13984280" + } + ], + "refuel": { + "action": "refuel", + "srcChainId": 10, + "destChainId": 137, + "protocol": { + "name": "refuel", + "displayName": "Refuel", + "icon": "" + }, + "srcAsset": { + "chainId": 10, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ether", + "decimals": 18 + }, + "destAsset": { + "chainId": 137, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "MATIC", + "name": "Matic", + "decimals": 18 + }, + "srcAmount": "1000000000000000", + "destAmount": "4405865573929566208" + } + }, + "approval": { + "chainId": 10, + "to": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000d59f80", + "gasLimit": 61865 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x038d7ea4c68000", + "data": "0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a500000000000000000000000000000000000000000000000000000000000000890000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000004a0c3540448000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000019d0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000284792ebcb90000000000000000000000000000000000000000000000000000000000d59f80000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000454000000000000000000000000000000000000000000000000000000000000000c40000000000000000000000000000000000000000000000000000000000000002000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c335900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000d55a40000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000067041c47000000000000000000000000000000000000000000000000000000006704704d00000000000000000000000000000000000000000000000000000000d00dfeeddeadbeef765753be7f7a64d5509974b0d678e1e3149b02f42c7402906f9888136205038026f20b3f6df2899044cab41d632bc7a6c35debd40516df85de6f194aeb05b72cb9ea4d5ce0f7c56c91a79536331112f1a846dc641c", + "gasLimit": 287227 + }, + "estimatedProcessingTimeInSeconds": 60 + }, + { + "quote": { + "requestId": "0b6caac9-456d-47e6-8982-1945ae81ae82", + "srcChainId": 10, + "srcAsset": { + "chainId": 10, + "address": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6, + "icon": "https://media.socket.tech/tokens/all/USDC", + "logoURI": "https://media.socket.tech/tokens/all/USDC", + "chainAgnosticId": null + }, + "srcTokenAmount": "14000000", + "destChainId": 137, + "destAsset": { + "chainId": 137, + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "symbol": "USDC", + "name": "Native USD Coin (POS)", + "decimals": 6, + "icon": "https://media.socket.tech/tokens/all/USDC", + "logoURI": "https://media.socket.tech/tokens/all/USDC", + "chainAgnosticId": "USDC" + }, + "destTokenAmount": "13800000", + "feeData": { + "metabridge": { + "amount": "0", + "asset": { + "chainId": 10, + "address": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6, + "icon": "https://media.socket.tech/tokens/all/USDC", + "logoURI": "https://media.socket.tech/tokens/all/USDC", + "chainAgnosticId": null + } + } + }, + "bridgeId": "socket", + "bridges": ["celercircle"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 137, + "protocol": { + "name": "cctp", + "displayName": "Circle CCTP", + "icon": "https://movricons.s3.ap-south-1.amazonaws.com/CCTP.svg" + }, + "srcAsset": { + "chainId": 10, + "address": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6, + "icon": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "logoURI": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "chainAgnosticId": null + }, + "destAsset": { + "chainId": 137, + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "symbol": "USDC", + "name": "Native USD Coin (POS)", + "decimals": 6, + "icon": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "logoURI": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "chainAgnosticId": "USDC" + }, + "srcAmount": "14000000", + "destAmount": "13800000" + } + ], + "refuel": { + "action": "refuel", + "srcChainId": 10, + "destChainId": 137, + "protocol": { + "name": "refuel", + "displayName": "Refuel", + "icon": "" + }, + "srcAsset": { + "chainId": 10, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ether", + "decimals": 18 + }, + "destAsset": { + "chainId": 137, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "MATIC", + "name": "Matic", + "decimals": 18 + }, + "srcAmount": "1000000000000000", + "destAmount": "4405865573929566208" + } + }, + "approval": { + "chainId": 10, + "to": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000d59f80", + "gasLimit": 61865 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x038d7ea4c68000", + "data": "0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004400000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a500000000000000000000000000000000000000000000000000000000000000890000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000002e4c3540448000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000018c0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e4b7dfe9d00000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c4000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000030d400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000138bc5930d51a475e4669db259f69e61ca33803675e76540f062a76af8cbaef4672c9926e56d6a8c29a263de3ee8f734ad760461c448f82fdccdd8c2360fffba1b", + "gasLimit": 343079 + }, + "estimatedProcessingTimeInSeconds": 1560 + } +] diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-native.json b/packages/bridge-controller/tests/mock-quotes-erc20-native.json new file mode 100644 index 00000000000..cd4a1963c6f --- /dev/null +++ b/packages/bridge-controller/tests/mock-quotes-erc20-native.json @@ -0,0 +1,894 @@ +[ + { + "quote": { + "requestId": "a63df72a-75ae-4416-a8ab-aff02596c75c", + "srcChainId": 10, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destChainId": 42161, + "destTokenAmount": "991225000000000000", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["stargate"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "stargate", + "displayName": "StargateV2 (Fast mode)", + "icon": "https://raw.githubusercontent.com/lifinance/types/5685c638772f533edad80fcb210b4bb89e30a50f/src/assets/icons/bridges/stargate.png" + }, + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 10, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3136", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "991225000000000000" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x1c8598b5db2e", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006c00000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000564a6010a660000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000000000000000000000000000003804bdedbea3f94faf8c8fac5ec841251d96cf5e64e8706ada4688877885e5249520000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000000000000000000000000000dc1a09f859b2000000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a7374617267617465563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000005215e9fd223bc909083fbdb2860213873046e45d0000000000000000000000005215e9fd223bc909083fbdb2860213873046e45d000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000043ccfd60b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000001c8598b5db2e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000759e000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000000000000000000000000000dc1a09f859b2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c83dc7c11df600d7293f778cb365d3dfcc1ffa2221cf5447a8f2ea407a97792135d9f585ecb68916479dfa1f071f169cbe1cfec831b5ad01f4e4caa09204e5181c", + "gasLimit": 641446 + }, + "estimatedProcessingTimeInSeconds": 64 + }, + { + "quote": { + "requestId": "aad73198-a64d-4310-b12d-9dcc81c412e2", + "srcChainId": 10, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destChainId": 42161, + "destTokenAmount": "991147696728676903", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["celer"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "celer", + "displayName": "Celer cBridge", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/cbridge.svg" + }, + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "991147696728676903" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000e7bf43c55551b1036e796e7fd3b125d1f9903e2e000000000000000000000000e7bf43c55551b1036e796e7fd3b125d1f9903e2e000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000050f68486970f93a855b27794b8141d32a89a1e0a5ef360034a2f60a4b917c188380000a4b1420000000000000000000000000000000000000600000000000000000dc1a09f859b20002c03873900002777000000000000000000000000000000002d68122053030bf8df41a8bb8c6f0a9de411c7d94eed376b7d91234e1585fd9f77dcf974dd25160d0c2c16c8382d8aa85b0edd429edff19b4d4cdcf50d0a9d4d1c", + "gasLimit": 203352 + }, + "estimatedProcessingTimeInSeconds": 53 + }, + { + "quote": { + "requestId": "6cfd4952-c9b2-4aec-9349-af39c212f84b", + "srcChainId": 10, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destChainId": 42161, + "destTokenAmount": "991112862890876485", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["across"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "across", + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png" + }, + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "991112862890876485" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000000902340ab8f6a57ef0c43231b98141d32a89a1e0a5ef360034a2f60a4b917c18838420000000000000000000000000000000000000600000000000000000dc1a09f859b20000000a4b100007dd39298f9ad673645ebffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b710000000000000000000000000000000088d06e7971021eee573a0ab6bc3e22039fc1c5ded5d12c4cf2b6311f47f909e06197aa8b2f647ae78ae33a6ea5d23f7c951c0e1686abecd01d7c796990d56f391c", + "gasLimit": 177423 + }, + "estimatedProcessingTimeInSeconds": 15 + }, + { + "quote": { + "requestId": "2c2ba7d8-3922-4081-9f27-63b7d5cc1986", + "srcChainId": 10, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destChainId": 42161, + "destTokenAmount": "990221346602370184", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["hop"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "hop", + "displayName": "Hop", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/hop.png" + }, + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 10, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3136", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "990221346602370184" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000484ca360ae0000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000001168a464edd170000000000000000000000000000000000000000000000000dac6213fc70c84400000000000000000000000000000000000000000000000000000000673a3b080000000000000000000000000000000000000000000000000dac6213fc70c84400000000000000000000000000000000000000000000000000000000673a3b0800000000000000000000000086ca30bef97fb651b8d866d45503684b90cb3312000000000000000000000000710bda329b2a6224e4b44833de30f38e7f81d5640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000067997b63db4b9059d22e50750707b46a6d48dfbb32e50d85fc3bff1170ed9ca30000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000000000000000000000000000dc1a09f859b2000000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003686f700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000005215e9fd223bc909083fbdb2860213873046e45d0000000000000000000000005215e9fd223bc909083fbdb2860213873046e45d000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000043ccfd60b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000099d00cde1f22e8afd37d7f103ec3c6c1eb835ace46e502ec8c5ab51413e539461b89c0e26892efd1de1cbfe4222b5589e76231080252197507cce4fb72a30b031b", + "gasLimit": 547501 + }, + "estimatedProcessingTimeInSeconds": 24.159 + }, + { + "quote": { + "requestId": "a77bc7b2-e8c8-4463-89db-5dd239d6aacc", + "srcChainId": 10, + "srcAsset": { + "chainId": 10, + "address": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/WETH", + "logoURI": "https://media.socket.tech/tokens/all/WETH", + "chainAgnosticId": "ETH" + }, + "srcTokenAmount": "991250000000000000", + "destChainId": 42161, + "destAsset": { + "chainId": 42161, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ethereum", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/ETH", + "logoURI": "https://media.socket.tech/tokens/all/ETH", + "chainAgnosticId": null + }, + "destTokenAmount": "991147696728676903", + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "chainId": 10, + "address": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/WETH", + "logoURI": "https://media.socket.tech/tokens/all/WETH", + "chainAgnosticId": "ETH" + } + } + }, + "bridgeId": "socket", + "bridges": ["celer"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "celer", + "displayName": "Celer", + "icon": "https://socketicons.s3.amazonaws.com/Celer+Light.png" + }, + "srcAsset": { + "chainId": 10, + "address": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/WETH", + "logoURI": "https://media.socket.tech/tokens/all/WETH", + "chainAgnosticId": "ETH" + }, + "destAsset": { + "chainId": 42161, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ethereum", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/ETH", + "logoURI": "https://media.socket.tech/tokens/all/ETH", + "chainAgnosticId": null + }, + "srcAmount": "991250000000000000", + "destAmount": "991147696728676903" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a5187000000000000000000000000000000000000000000000000000000000000004c0000001252106ce9141d32a89a1e0a5ef360034a2f60a4b917c18838420000000000000000000000000000000000000600000000000000000dc1a09f859b20000000a4b1245fa5dd00002777000000000000000000000000000000000000000022be703a074ef6089a301c364c2bbf391d51067ea5cd91515c9ec5421cdaabb23451cd2086f3ebe3e19ff138f3a9be154dcae6033838cc5fabeeb0d260b075cb1c", + "gasLimit": 182048 + }, + "estimatedProcessingTimeInSeconds": 360 + }, + { + "quote": { + "requestId": "4f2154d9b330221b2ad461adf63acc2c", + "srcChainId": 10, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "id": "10_0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "name": "Wrapped ETH", + "decimals": 18, + "usdPrice": 3135.9632118339764, + "coingeckoId": "weth", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg", + "volatility": 2, + "axelarNetworkSymbol": "WETH", + "subGraphIds": [], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg" + }, + "destChainId": 42161, + "destTokenAmount": "989989428114299041", + "destAsset": { + "id": "42161_0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "symbol": "ETH", + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "name": "ETH", + "decimals": 18, + "usdPrice": 3133.259355489038, + "coingeckoId": "ethereum", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/eth.svg", + "volatility": 2, + "axelarNetworkSymbol": "ETH", + "subGraphIds": ["chainflip-bridge"], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/eth.svg" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "id": "10_0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "name": "Wrapped ETH", + "decimals": 18, + "usdPrice": 3135.9632118339764, + "coingeckoId": "weth", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg", + "volatility": 2, + "axelarNetworkSymbol": "WETH", + "subGraphIds": [], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg" + } + } + }, + "bridgeId": "squid", + "bridges": ["axelar"], + "steps": [ + { + "action": "swap", + "srcChainId": 10, + "destChainId": 10, + "protocol": { + "name": "Uniswap V3", + "displayName": "Uniswap V3" + }, + "srcAsset": { + "id": "10_0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "name": "Wrapped ETH", + "decimals": 18, + "usdPrice": 3135.9632118339764, + "coingeckoId": "weth", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg", + "axelarNetworkSymbol": "WETH", + "subGraphIds": [], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg" + }, + "destAsset": { + "id": "10_0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "symbol": "USDC", + "address": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "chainId": 10, + "name": "USDC", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc", "cctp-uusdc-optimism-to-noble"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "srcAmount": "991250000000000000", + "destAmount": "3100880215" + }, + { + "action": "swap", + "srcChainId": 10, + "destChainId": 10, + "protocol": { + "name": "Uniswap V3", + "displayName": "Uniswap V3" + }, + "srcAsset": { + "id": "10_0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "symbol": "USDC", + "address": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "chainId": 10, + "name": "USDC", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc", "cctp-uusdc-optimism-to-noble"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "destAsset": { + "id": "10_0x7f5c764cbc14f9669b88837ca1490cca17c31607", + "symbol": "USDC.e", + "address": "0x7f5c764cbc14f9669b88837ca1490cca17c31607", + "chainId": 10, + "name": "USDC.e", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC.e", + "subGraphIds": [], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "srcAmount": "3100880215", + "destAmount": "3101045779" + }, + { + "action": "swap", + "srcChainId": 10, + "destChainId": 10, + "protocol": { + "name": "Uniswap V3", + "displayName": "Uniswap V3" + }, + "srcAsset": { + "id": "10_0x7f5c764cbc14f9669b88837ca1490cca17c31607", + "symbol": "USDC.e", + "address": "0x7f5c764cbc14f9669b88837ca1490cca17c31607", + "chainId": 10, + "name": "USDC.e", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC.e", + "subGraphIds": [], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "destAsset": { + "id": "10_0xeb466342c4d449bc9f53a865d5cb90586f405215", + "symbol": "USDC.axl", + "address": "0xeb466342c4d449bc9f53a865d5cb90586f405215", + "chainId": 10, + "name": " USDC (Axelar)", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "interchainTokenId": null, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "axlUSDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg" + }, + "srcAmount": "3101045779", + "destAmount": "3101521947" + }, + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "axelar", + "displayName": "Axelar" + }, + "srcAsset": { + "id": "10_0xeb466342c4d449bc9f53a865d5cb90586f405215", + "symbol": "USDC.axl", + "address": "0xeb466342c4d449bc9f53a865d5cb90586f405215", + "chainId": 10, + "name": " USDC (Axelar)", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "interchainTokenId": null, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "axlUSDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg" + }, + "destAsset": { + "id": "42161_0xeb466342c4d449bc9f53a865d5cb90586f405215", + "symbol": "USDC.axl", + "address": "0xeb466342c4d449bc9f53a865d5cb90586f405215", + "chainId": 42161, + "name": " USDC (Axelar)", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "interchainTokenId": null, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "axlUSDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg" + }, + "srcAmount": "3101521947", + "destAmount": "3101521947" + }, + { + "action": "swap", + "srcChainId": 42161, + "destChainId": 42161, + "protocol": { + "name": "Pancakeswap V3", + "displayName": "Pancakeswap V3" + }, + "srcAsset": { + "id": "42161_0xeb466342c4d449bc9f53a865d5cb90586f405215", + "symbol": "USDC.axl", + "address": "0xeb466342c4d449bc9f53a865d5cb90586f405215", + "chainId": 42161, + "name": " USDC (Axelar)", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "interchainTokenId": null, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "axlUSDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg" + }, + "destAsset": { + "id": "42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "symbol": "USDC", + "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "chainId": 42161, + "name": "USDC", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC", + "subGraphOnly": false, + "subGraphIds": [ + "uusdc", + "cctp-uusdc-arbitrum-to-noble", + "chainflip-bridge" + ], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "srcAmount": "3101521947", + "destAmount": "3100543869" + }, + { + "action": "swap", + "srcChainId": 42161, + "destChainId": 42161, + "protocol": { + "name": "Uniswap V3", + "displayName": "Uniswap V3" + }, + "srcAsset": { + "id": "42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "symbol": "USDC", + "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "chainId": 42161, + "name": "USDC", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC", + "subGraphOnly": false, + "subGraphIds": [ + "uusdc", + "cctp-uusdc-arbitrum-to-noble", + "chainflip-bridge" + ], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "destAsset": { + "id": "42161_0x82af49447d8a07e3bd95bd0d56f35241523fbab1", + "symbol": "WETH", + "address": "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", + "chainId": 42161, + "name": "Wrapped ETH", + "decimals": 18, + "usdPrice": 3135.9632118339764, + "interchainTokenId": null, + "coingeckoId": "weth", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/weth.svg", + "axelarNetworkSymbol": "WETH", + "subGraphOnly": false, + "subGraphIds": ["arbitrum-weth-wei"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/weth.svg" + }, + "srcAmount": "3100543869", + "destAmount": "989989428114299041" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x4653ce53e6b1", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000e73717569644164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001b60000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000001a14846a1bc600000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000ce00000000000000000000000000000000000000000000000000000000000000d200000000000000000000000000000000000000000000000000000000000000d600000000000000000000000000000000000000000000000000000000000000dc0000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000046000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000098000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf00000000000000000000000042000000000000000000000000000000000000060000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff8500000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c0000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000b8833d8e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c00000000000000000000000000000000000000000000000000000000b8d3ad5700000000000000000000000000000000000000000000000000000000b8c346b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c31607000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf0000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c31607000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d66600000000000000000000000000000000000000000000000000000000b8d6341300000000000000000000000000000000000000000000000000000000b8ca89fa00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000761786c55534443000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008417262697472756d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a307863653136463639333735353230616230313337376365374238386635424138433438463844363636000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c100000000000000000000000000000000000000000000000000000000000000040000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000003600000000000000000000000000000000000000000000000000000000000000580000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000009200000000000000000000000000000000000000000000000000000000000000a8000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000000000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000032226588378236fd0c7c4053999f88ac0e5cac77ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000032226588378236fd0c7c4053999f88ac0e5cac77000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c00000000000000000000000000000000000000000000000000000000b8dd781b00000000000000000000000000000000000000000000000000000000b8bb9ee30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f40521500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c00000000000000000000000000000000000000000000000000000000b8ce8b7d0000000000000000000000000000000000000000000000000db72b79f837011c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000100000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000242e1a7d4d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee00000000000000000000000000000000000000000000000000000000000000004f2154d9b330221b2ad461adf63acc2c000000000000000000000000000000004f2154d9b330221b2ad461adf63acc2c0000000000000000000000003c17c95cdb5887c334bfae85750ce00e1a720a76eff35e60db6c9f3b8384a6d63db3c56f1ce6545b50ba2f250429055ca77e7e6203ddd65a7a4d89ae1af3d61b1c", + "gasLimit": 710342 + }, + "estimatedProcessingTimeInSeconds": 20 + } +] diff --git a/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.json b/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.json new file mode 100644 index 00000000000..0afd77760e7 --- /dev/null +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.json @@ -0,0 +1,258 @@ +[ + { + "quote": { + "requestId": "34c4136d-8558-4d87-bdea-eef8d2d30d6d", + "srcChainId": 1, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destChainId": 42161, + "destTokenAmount": "3104367033", + "destAsset": { + "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "chainId": 42161, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9998000399920016", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["across"], + "steps": [ + { + "action": "swap", + "srcChainId": 1, + "destChainId": 1, + "protocol": { + "name": "0x", + "displayName": "0x", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png" + }, + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destAsset": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9997000899730081", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "3104701473" + }, + { + "action": "bridge", + "srcChainId": 1, + "destChainId": 42161, + "protocol": { + "name": "across", + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png" + }, + "srcAsset": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9997000899730081", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "destAsset": { + "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "chainId": 42161, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9998000399920016", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "srcAmount": "3104701473", + "destAmount": "3104367033" + } + ] + }, + "trade": { + "chainId": 1, + "to": "0x0439e60F02a8900a951603950d8D4527f400C3f1", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x0de0b6b3a7640000", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c696669416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de51520000000000000000000000000000000000000000000000000000000000000a003a3f733200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000000000000000000000000000094027363a1fac5600d1f7e8a4c50087ff1f32a09359512d2379d46b331c6033cc7b000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000b8211d6e000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066163726f73730000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000005c42213bc0b00000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f471000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f471000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000004e41fff991f0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b909399a00000000000000000000000000000000000000000000000000000000000000a094cc69295a8f2a3016ede239627ab300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000010438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000002710000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000024d0e30db00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e48d68a15600000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f4710000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002cc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2010001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012438c9c147000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000005000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000002400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000620541d325b000000000000000000000000000000000000000000000000000000000673656d70000000000000000000000000000000000000000000000000000000000000080ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000d00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b71dcbfe555f9a744b18195d9b52032871d6f3c5a558275c08a71c2b6214801f5161be976f49181b854a3ebcbe1f2b896133b03314a5ff2746e6494c43e59d0c9ee1c", + "gasLimit": 540076 + }, + "estimatedProcessingTimeInSeconds": 45 + }, + { + "quote": { + "requestId": "5bf0f2f0-655c-4e13-a545-1ebad6f9d2bc", + "srcChainId": 1, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destChainId": 42161, + "destTokenAmount": "3104601473", + "destAsset": { + "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "chainId": 42161, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9998000399920016", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["celercircle"], + "steps": [ + { + "action": "swap", + "srcChainId": 1, + "destChainId": 1, + "protocol": { + "name": "0x", + "displayName": "0x", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png" + }, + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destAsset": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9997000899730081", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "3104701473" + }, + { + "action": "bridge", + "srcChainId": 1, + "destChainId": 42161, + "protocol": { + "name": "celercircle", + "displayName": "Circle CCTP", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/circle.png" + }, + "srcAsset": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9997000899730081", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "destAsset": { + "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "chainId": 42161, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9998000399920016", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "srcAmount": "3104701473", + "destAmount": "3104601473" + } + ] + }, + "trade": { + "chainId": 1, + "to": "0x0439e60F02a8900a951603950d8D4527f400C3f1", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x0de0b6b3a7640000", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c696669416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a800000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000009248fab066300000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000200b431adcab44c6fe13ade53dbd3b714f57922ab5b776924a913685ad0fe680f6c000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000b8211d6e000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b63656c6572636972636c65000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000005c42213bc0b00000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f471000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f471000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000004e41fff991f0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b909399a00000000000000000000000000000000000000000000000000000000000000a0c0452b52ecb7cf70409b16cd627ab300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000010438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000002710000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000024d0e30db00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e48d68a15600000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f4710000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002cc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2010001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012438c9c147000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000005000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000002400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000047896dca097909ba9db4c9631bce0e53090bce14a9b7d203e21fa80cee7a16fa049aa1ef7d663c2ec3148e698e01774b62ddedc9c2dcd21994e549cd6f318f971b", + "gasLimit": 682910 + }, + "estimatedProcessingTimeInSeconds": 1029.717 + } +] diff --git a/packages/bridge-controller/tests/mock-quotes-native-erc20.json b/packages/bridge-controller/tests/mock-quotes-native-erc20.json new file mode 100644 index 00000000000..f7efe7950ba --- /dev/null +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20.json @@ -0,0 +1,294 @@ +[ + { + "quote": { + "requestId": "381c23bc-e3e4-48fe-bc53-257471e388ad", + "srcChainId": 10, + "srcAsset": { + "chainId": 10, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ethereum", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/ETH", + "logoURI": "https://media.socket.tech/tokens/all/ETH", + "chainAgnosticId": null + }, + "srcTokenAmount": "9912500000000000", + "destChainId": 137, + "destAsset": { + "chainId": 137, + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "symbol": "USDC", + "name": "Native USD Coin (POS)", + "decimals": 6, + "icon": "https://media.socket.tech/tokens/all/USDC", + "logoURI": "https://media.socket.tech/tokens/all/USDC", + "chainAgnosticId": "USDC" + }, + "destTokenAmount": "24438902", + "feeData": { + "metabridge": { + "amount": "87500000000000", + "asset": { + "chainId": 10, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ethereum", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/ETH", + "logoURI": "https://media.socket.tech/tokens/all/ETH", + "chainAgnosticId": null + } + } + }, + "bridgeId": "socket", + "bridges": ["across"], + "steps": [ + { + "action": "swap", + "srcChainId": 10, + "protocol": { + "name": "zerox", + "displayName": "0x", + "icon": "https://media.socket.tech/dexes/0x.svg" + }, + "srcAsset": { + "chainId": 10, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ethereum", + "decimals": 18, + "icon": "https://assets.polygon.technology/tokenAssets/eth.svg", + "logoURI": "https://assets.polygon.technology/tokenAssets/eth.svg", + "chainAgnosticId": null + }, + "destAsset": { + "chainId": 10, + "address": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6, + "icon": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "logoURI": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "chainAgnosticId": null + }, + "srcAmount": "9912500000000000", + "destAmount": "24456223" + }, + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 137, + "protocol": { + "name": "across", + "displayName": "Across", + "icon": "https://miro.medium.com/max/800/1*PN_F5yW4VMBgs_xX-fsyzQ.png" + }, + "srcAsset": { + "chainId": 10, + "address": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6, + "icon": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "logoURI": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "chainAgnosticId": null + }, + "destAsset": { + "chainId": 137, + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "symbol": "USDC", + "name": "Native USD Coin (POS)", + "decimals": 6, + "icon": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "logoURI": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "chainAgnosticId": "USDC" + }, + "srcAmount": "24456223", + "destAmount": "24438902" + } + ], + "refuel": { + "action": "refuel", + "srcChainId": 10, + "destChainId": 137, + "protocol": { + "name": "refuel", + "displayName": "Refuel", + "icon": "" + }, + "srcAsset": { + "chainId": 10, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ether", + "decimals": 18 + }, + "destAsset": { + "chainId": 137, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "MATIC", + "name": "Matic", + "decimals": 18 + }, + "srcAmount": "1000000000000000", + "destAmount": "4405865573929566208" + } + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x27147114878000", + "data": "0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002714711487800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b657441646170746572563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f600000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000000000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000004f94ae6af800000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000e2037c6145a0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000d64123506490000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001960000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000019d0000000000000000000000000000000000000000000000000000000000000ac00000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000904ee8f0b86000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000023375dc156080000000000000000000000000000000000000000000000000000000000000000c400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000828415565b0000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000023375dc15608000000000000000000000000000000000000000000000000000000000001734d0800000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000004e000000000000000000000000000000000000000000000000000000000000005e0000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000023375dc15608000000000000000000000000000000000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000003600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff8500000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000002e00000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000012556e69737761705633000000000000000000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000173dbd3000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000e592427a0aece92de3edee1f18e0157c0586156400000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002b42000000000000000000000000000000000000060001f40b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000008ecb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce5000000000000000000000000000000000000000000000000000000000000000b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000004200000000000000000000000000000000000006000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000000000869584cd0000000000000000000000001000000000000000000000000000000000000011000000000000000000000000000000000000000021582def464917822ff6092c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000260000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000043a900000000000000000000000000000000000000000000000000000000000000c40000000000000000000000000000000000000000000000000000000000000002000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000174e7be000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000067041c47000000000000000000000000000000000000000000000000000000006704704d00000000000000000000000000000000000000000000000000000000d00dfeeddeadbeef765753be7f7a64d5509974b0d678e1e3149b02f41fec59a4aef7d9ac92ee5eeaf293cb28c2261e7fd322723a97cb83762f7302296636026e52849fdad0f9db6e1640f914660e6b13f5b1a29345344c8c5687abbf1b", + "gasLimit": 610414 + }, + "estimatedProcessingTimeInSeconds": 60 + }, + { + "quote": { + "requestId": "4277a368-40d7-4e82-aa67-74f29dc5f98a", + "srcChainId": 10, + "srcAsset": { + "chainId": 10, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ethereum", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/ETH", + "logoURI": "https://media.socket.tech/tokens/all/ETH", + "chainAgnosticId": null + }, + "srcTokenAmount": "9912500000000000", + "destChainId": 137, + "destAsset": { + "chainId": 137, + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "symbol": "USDC", + "name": "Native USD Coin (POS)", + "decimals": 6, + "icon": "https://media.socket.tech/tokens/all/USDC", + "logoURI": "https://media.socket.tech/tokens/all/USDC", + "chainAgnosticId": "USDC" + }, + "destTokenAmount": "24256223", + "feeData": { + "metabridge": { + "amount": "87500000000000", + "asset": { + "chainId": 10, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ethereum", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/ETH", + "logoURI": "https://media.socket.tech/tokens/all/ETH", + "chainAgnosticId": null + } + } + }, + "bridgeId": "socket", + "bridges": ["celercircle"], + "steps": [ + { + "action": "swap", + "srcChainId": 10, + "protocol": { + "name": "zerox", + "displayName": "0x", + "icon": "https://media.socket.tech/dexes/0x.svg" + }, + "srcAsset": { + "chainId": 10, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ethereum", + "decimals": 18, + "icon": "https://assets.polygon.technology/tokenAssets/eth.svg", + "logoURI": "https://assets.polygon.technology/tokenAssets/eth.svg", + "chainAgnosticId": null + }, + "destAsset": { + "chainId": 10, + "address": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6, + "icon": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "logoURI": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "chainAgnosticId": null + }, + "srcAmount": "9912500000000000", + "destAmount": "24456223" + }, + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 137, + "protocol": { + "name": "cctp", + "displayName": "Circle CCTP", + "icon": "https://movricons.s3.ap-south-1.amazonaws.com/CCTP.svg" + }, + "srcAsset": { + "chainId": 10, + "address": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6, + "icon": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "logoURI": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "chainAgnosticId": null + }, + "destAsset": { + "chainId": 137, + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "symbol": "USDC", + "name": "Native USD Coin (POS)", + "decimals": 6, + "icon": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "logoURI": "https://assets.polygon.technology/tokenAssets/usdc.svg", + "chainAgnosticId": "USDC" + }, + "srcAmount": "24456223", + "destAmount": "24256223" + } + ], + "refuel": { + "action": "refuel", + "srcChainId": 10, + "destChainId": 137, + "protocol": { + "name": "refuel", + "displayName": "Refuel", + "icon": "" + }, + "srcAsset": { + "chainId": 10, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ether", + "decimals": 18 + }, + "destAsset": { + "chainId": 137, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "MATIC", + "name": "Matic", + "decimals": 18 + }, + "srcAmount": "1000000000000000", + "destAmount": "4405865573929566208" + } + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x27147114878000", + "data": "0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002714711487800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b657441646170746572563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000000000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000004f94ae6af800000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000c6437c6145a0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000bc4123506490000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001960000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000018c0000000000000000000000000000000000000000000000000000000000000ac00000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000904ee8f0b86000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000023375dc156080000000000000000000000000000000000000000000000000000000000000000c400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000828415565b0000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000023375dc15608000000000000000000000000000000000000000000000000000000000001734d0800000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000004e000000000000000000000000000000000000000000000000000000000000005e0000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000023375dc15608000000000000000000000000000000000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000003600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff8500000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000002e00000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000012556e69737761705633000000000000000000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000173dbd3000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000e592427a0aece92de3edee1f18e0157c0586156400000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002b42000000000000000000000000000000000000060001f40b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000008ecb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce5000000000000000000000000000000000000000000000000000000000000000b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000004200000000000000000000000000000000000006000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000000000869584cd00000000000000000000000010000000000000000000000000000000000000110000000000000000000000000000000000000000974132b87a5cb75e32f034280000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000030d4000000000000000000000000000000000000000000000000000000000000000c400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003f9e43204a24f476db20f2518722627a122d31a1bc7c63fc15412e6a327295a9460b76bea5bb53b1f73fa6a15811055f6bada592d2e9e6c8cf48a855ce6968951c", + "gasLimit": 664389 + }, + "estimatedProcessingTimeInSeconds": 15 + } +] diff --git a/packages/bridge-controller/tsconfig.build.json b/packages/bridge-controller/tsconfig.build.json new file mode 100644 index 00000000000..b62ec3ff054 --- /dev/null +++ b/packages/bridge-controller/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../accounts-controller/tsconfig.build.json" }, + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../network-controller/tsconfig.build.json" }, + { "path": "../polling-controller/tsconfig.build.json" }, + { "path": "../transaction-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/bridge-controller/tsconfig.json b/packages/bridge-controller/tsconfig.json new file mode 100644 index 00000000000..3f93de1f5e6 --- /dev/null +++ b/packages/bridge-controller/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "resolveJsonModule": true + }, + "references": [ + { "path": "../accounts-controller" }, + { "path": "../base-controller" }, + { "path": "../controller-utils" }, + { "path": "../network-controller" }, + { "path": "../polling-controller" }, + { "path": "../transaction-controller" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/bridge-controller/typedoc.json b/packages/bridge-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/bridge-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/build-utils/CHANGELOG.md b/packages/build-utils/CHANGELOG.md index a2f575f2089..3ac59171823 100644 --- a/packages/build-utils/CHANGELOG.md +++ b/packages/build-utils/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.3] + +### Changed + +- Bump `@metamask/utils` from `^10.0.0` to `^11.1.0` ([#5080](https://github.com/MetaMask/core/pull/5080)), ([#5223](https://github.com/MetaMask/core/pull/5223)) + ## [3.0.2] ### Changed @@ -75,7 +81,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#3577](https://github.com/MetaMask/core/pull/3577) [#3588](https://github.com/MetaMask/core/pull/3588)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/build-utils@3.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/build-utils@3.0.3...HEAD +[3.0.3]: https://github.com/MetaMask/core/compare/@metamask/build-utils@3.0.2...@metamask/build-utils@3.0.3 [3.0.2]: https://github.com/MetaMask/core/compare/@metamask/build-utils@3.0.1...@metamask/build-utils@3.0.2 [3.0.1]: https://github.com/MetaMask/core/compare/@metamask/build-utils@3.0.0...@metamask/build-utils@3.0.1 [3.0.0]: https://github.com/MetaMask/core/compare/@metamask/build-utils@2.0.1...@metamask/build-utils@3.0.0 diff --git a/packages/build-utils/package.json b/packages/build-utils/package.json index 9832ffd2404..d7868a3daa4 100644 --- a/packages/build-utils/package.json +++ b/packages/build-utils/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/build-utils", - "version": "3.0.2", + "version": "3.0.3", "description": "Utilities for building MetaMask applications", "keywords": [ "MetaMask", @@ -47,7 +47,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/utils": "^11.0.1", + "@metamask/utils": "^11.1.0", "@types/eslint": "^8.44.7" }, "devDependencies": { diff --git a/packages/composable-controller/CHANGELOG.md b/packages/composable-controller/CHANGELOG.md index 15c5eb02d48..5dd54ea9404 100644 --- a/packages/composable-controller/CHANGELOG.md +++ b/packages/composable-controller/CHANGELOG.md @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [11.0.0] + ### Changed -- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) +- **BREAKING:** Re-define `ComposableControllerStateConstraint` type using `StateConstraint` instead of `LegacyControllerStateConstraint` ([#5018](https://github.com/MetaMask/core/pull/5018/)) +- **BREAKING:** Constrain the `ComposableControllerState` generic argument for the `ComposableController` class using `ComposableControllerStateConstraint` instead of `LegacyComposableControllerStateConstraint` ([#5018](https://github.com/MetaMask/core/pull/5018/)) +- Bump `@metamask/base-controller` from `^7.0.2` to `^8.0.0` ([#5079](https://github.com/MetaMask/core/pull/5079)), ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/json-rpc-engine` from `^10.0.1` to `^10.0.3` ([#5082](https://github.com/MetaMask/core/pull/5082)), ([#5272](https://github.com/MetaMask/core/pull/5272)) ## [10.0.0] @@ -216,7 +221,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/composable-controller@10.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@11.0.0...HEAD +[11.0.0]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@10.0.0...@metamask/composable-controller@11.0.0 [10.0.0]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@9.0.1...@metamask/composable-controller@10.0.0 [9.0.1]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@9.0.0...@metamask/composable-controller@9.0.1 [9.0.0]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@8.0.0...@metamask/composable-controller@9.0.0 diff --git a/packages/composable-controller/package.json b/packages/composable-controller/package.json index 786e1161db7..d303e77ffeb 100644 --- a/packages/composable-controller/package.json +++ b/packages/composable-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/composable-controller", - "version": "10.0.0", + "version": "11.0.0", "description": "Consolidates the state from multiple controllers into one", "keywords": [ "MetaMask", @@ -47,11 +47,11 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1" + "@metamask/base-controller": "^8.0.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/json-rpc-engine": "^10.0.2", + "@metamask/json-rpc-engine": "^10.0.3", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "immer": "^9.0.6", diff --git a/packages/composable-controller/src/ComposableController.test.ts b/packages/composable-controller/src/ComposableController.test.ts index 8a04da0ba34..68eb46d3742 100644 --- a/packages/composable-controller/src/ComposableController.test.ts +++ b/packages/composable-controller/src/ComposableController.test.ts @@ -1,15 +1,5 @@ -// `ComposableControllerState` type objects are keyed with controller names written in PascalCase. -/* eslint-disable @typescript-eslint/naming-convention */ - -import type { - BaseState, - RestrictedControllerMessenger, -} from '@metamask/base-controller'; -import { - BaseController, - BaseControllerV1, - ControllerMessenger, -} from '@metamask/base-controller'; +import type { RestrictedMessenger } from '@metamask/base-controller'; +import { BaseController, Messenger } from '@metamask/base-controller'; import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import type { Patch } from 'immer'; import * as sinon from 'sinon'; @@ -33,7 +23,7 @@ type FooControllerEvent = { payload: [FooControllerState, Patch[]]; }; -type FooMessenger = RestrictedControllerMessenger< +type FooMessenger = RestrictedMessenger< 'FooController', never, FooControllerEvent | QuzControllerEvent, @@ -77,7 +67,7 @@ type QuzControllerEvent = { payload: [QuzControllerState, Patch[]]; }; -type QuzMessenger = RestrictedControllerMessenger< +type QuzMessenger = RestrictedMessenger< 'QuzController', never, QuzControllerEvent, @@ -113,66 +103,11 @@ class QuzController extends BaseController< } } -// Mock BaseControllerV1 classes - -type BarControllerState = BaseState & { - bar: string; -}; - -class BarController extends BaseControllerV1 { - defaultState = { - bar: 'bar', - }; - - override name = 'BarController' as const; - - constructor() { - super(); - this.initialize(); - } - - updateBar(bar: string) { - super.update({ bar }); - } -} - -type BazControllerState = BaseState & { - baz: string; -}; -type BazControllerEvent = { - type: `BazController:stateChange`; - payload: [BazControllerState, Patch[]]; -}; - -type BazMessenger = RestrictedControllerMessenger< - 'BazController', - never, - BazControllerEvent, - never, - never ->; - -class BazController extends BaseControllerV1 { - defaultState = { - baz: 'baz', - }; - - override name = 'BazController' as const; - - protected messagingSystem: BazMessenger; - - constructor({ messenger }: { messenger: BazMessenger }) { - super(); - this.initialize(); - this.messagingSystem = messenger; - } -} - type ControllerWithoutStateChangeEventState = { qux: string; }; -type ControllerWithoutStateChangeEventMessenger = RestrictedControllerMessenger< +type ControllerWithoutStateChangeEventMessenger = RestrictedMessenger< 'ControllerWithoutStateChangeEvent', never, QuzControllerEvent, @@ -211,8 +146,6 @@ class ControllerWithoutStateChangeEvent extends BaseController< type ControllersMap = { FooController: FooController; QuzController: QuzController; - BarController: BarController; - BazController: BazController; ControllerWithoutStateChangeEvent: ControllerWithoutStateChangeEvent; }; @@ -221,99 +154,19 @@ describe('ComposableController', () => { sinon.restore(); }); - describe('BaseControllerV1', () => { - it('should compose controller state', () => { - type ComposableControllerState = { - BarController: BarControllerState; - BazController: BazControllerState; - }; - - const composableMessenger = new ControllerMessenger< - never, - | ComposableControllerEvents - | ChildControllerStateChangeEvents - >().getRestricted({ - name: 'ComposableController', - allowedActions: [], - allowedEvents: [ - 'BarController:stateChange', - 'BazController:stateChange', - ], - }); - const controller = new ComposableController< - ComposableControllerState, - Pick - >({ - controllers: { - BarController: new BarController(), - BazController: new BazController({ - messenger: new ControllerMessenger().getRestricted({ - name: 'BazController', - allowedActions: [], - allowedEvents: [], - }), - }), - }, - messenger: composableMessenger, - }); - - expect(controller.state).toStrictEqual({ - BarController: { bar: 'bar' }, - BazController: { baz: 'baz' }, - }); - }); - - it('should notify listeners of nested state change', () => { - type ComposableControllerState = { - BarController: BarControllerState; - }; - const controllerMessenger = new ControllerMessenger< - never, - | ComposableControllerEvents - | ChildControllerStateChangeEvents - >(); - const composableMessenger = controllerMessenger.getRestricted({ - name: 'ComposableController', - allowedActions: [], - allowedEvents: ['BarController:stateChange'], - }); - const barController = new BarController(); - new ComposableController< - ComposableControllerState, - Pick - >({ - controllers: { BarController: barController }, - messenger: composableMessenger, - }); - const listener = sinon.stub(); - controllerMessenger.subscribe( - 'ComposableController:stateChange', - listener, - ); - barController.updateBar('something different'); - - expect(listener.calledOnce).toBe(true); - expect(listener.getCall(0).args[0]).toStrictEqual({ - BarController: { - bar: 'something different', - }, - }); - }); - }); - describe('BaseControllerV2', () => { it('should compose controller state', () => { type ComposableControllerState = { FooController: FooControllerState; QuzController: QuzControllerState; }; - const controllerMessenger = new ControllerMessenger< + const messenger = new Messenger< never, | ComposableControllerEvents | FooControllerEvent | QuzControllerEvent >(); - const fooMessenger = controllerMessenger.getRestricted< + const fooMessenger = messenger.getRestricted< 'FooController', never, QuzControllerEvent['type'] @@ -322,7 +175,7 @@ describe('ComposableController', () => { allowedActions: [], allowedEvents: ['QuzController:stateChange'], }); - const quzMessenger = controllerMessenger.getRestricted({ + const quzMessenger = messenger.getRestricted({ name: 'QuzController', allowedActions: [], allowedEvents: [], @@ -330,7 +183,7 @@ describe('ComposableController', () => { const fooController = new FooController(fooMessenger); const quzController = new QuzController(quzMessenger); - const composableControllerMessenger = controllerMessenger.getRestricted({ + const composableControllerMessenger = messenger.getRestricted({ name: 'ComposableController', allowedActions: [], allowedEvents: [ @@ -358,18 +211,18 @@ describe('ComposableController', () => { type ComposableControllerState = { FooController: FooControllerState; }; - const controllerMessenger = new ControllerMessenger< + const messenger = new Messenger< never, | ComposableControllerEvents | FooControllerEvent >(); - const fooControllerMessenger = controllerMessenger.getRestricted({ + const fooControllerMessenger = messenger.getRestricted({ name: 'FooController', allowedActions: [], allowedEvents: [], }); const fooController = new FooController(fooControllerMessenger); - const composableControllerMessenger = controllerMessenger.getRestricted({ + const composableControllerMessenger = messenger.getRestricted({ name: 'ComposableController', allowedActions: [], allowedEvents: ['FooController:stateChange'], @@ -385,253 +238,150 @@ describe('ComposableController', () => { }); const listener = sinon.stub(); - controllerMessenger.subscribe( - 'ComposableController:stateChange', - listener, - ); - fooController.updateFoo('bar'); + messenger.subscribe('ComposableController:stateChange', listener); + fooController.updateFoo('qux'); expect(listener.calledOnce).toBe(true); expect(listener.getCall(0).args[0]).toStrictEqual({ FooController: { - foo: 'bar', + foo: 'qux', }, }); }); }); - describe('Mixed BaseControllerV1 and BaseControllerV2', () => { - it('should compose controller state', () => { - type ComposableControllerState = { - BarController: BarControllerState; - FooController: FooControllerState; - }; - const barController = new BarController(); - const controllerMessenger = new ControllerMessenger< - never, - | ComposableControllerEvents - | ChildControllerStateChangeEvents - >(); - const fooControllerMessenger = controllerMessenger.getRestricted({ - name: 'FooController', - allowedActions: [], - allowedEvents: [], - }); - const fooController = new FooController(fooControllerMessenger); - const composableControllerMessenger = controllerMessenger.getRestricted({ - name: 'ComposableController', - allowedActions: [], - allowedEvents: [ - 'BarController:stateChange', - 'FooController:stateChange', - ], - }); - const composableController = new ComposableController< - ComposableControllerState, - Pick - >({ - controllers: { - BarController: barController, - FooController: fooController, - }, - messenger: composableControllerMessenger, - }); - expect(composableController.state).toStrictEqual({ - BarController: { bar: 'bar' }, - FooController: { foo: 'foo' }, - }); + it('should notify listeners of BaseControllerV2 state change', () => { + type ComposableControllerState = { + QuzController: QuzControllerState; + FooController: FooControllerState; + }; + const messenger = new Messenger< + never, + | ComposableControllerEvents + | ChildControllerStateChangeEvents + >(); + const quzControllerMessenger = messenger.getRestricted({ + name: 'QuzController', + allowedActions: [], + allowedEvents: [], }); - - it('should notify listeners of BaseControllerV1 state change', () => { - type ComposableControllerState = { - BarController: BarControllerState; - FooController: FooControllerState; - }; - const barController = new BarController(); - const controllerMessenger = new ControllerMessenger< - never, - | ComposableControllerEvents - | ChildControllerStateChangeEvents - >(); - const fooControllerMessenger = controllerMessenger.getRestricted({ - name: 'FooController', - allowedActions: [], - allowedEvents: [], - }); - const fooController = new FooController(fooControllerMessenger); - const composableControllerMessenger = controllerMessenger.getRestricted({ - name: 'ComposableController', - allowedActions: [], - allowedEvents: [ - 'BarController:stateChange', - 'FooController:stateChange', - ], - }); - new ComposableController< - ComposableControllerState, - Pick - >({ - controllers: { - BarController: barController, - FooController: fooController, - }, - messenger: composableControllerMessenger, - }); - const listener = sinon.stub(); - controllerMessenger.subscribe( - 'ComposableController:stateChange', - listener, - ); - barController.updateBar('foo'); - - expect(listener.calledOnce).toBe(true); - expect(listener.getCall(0).args[0]).toStrictEqual({ - BarController: { - bar: 'foo', - }, - FooController: { - foo: 'foo', - }, - }); + const quzController = new QuzController(quzControllerMessenger); + const fooControllerMessenger = messenger.getRestricted({ + name: 'FooController', + allowedActions: [], + allowedEvents: [], }); - - it('should notify listeners of BaseControllerV2 state change', () => { - type ComposableControllerState = { - BarController: BarControllerState; - FooController: FooControllerState; - }; - const barController = new BarController(); - const controllerMessenger = new ControllerMessenger< - never, - | ComposableControllerEvents - | ChildControllerStateChangeEvents - >(); - const fooControllerMessenger = controllerMessenger.getRestricted({ - name: 'FooController', - allowedActions: [], - allowedEvents: [], - }); - const fooController = new FooController(fooControllerMessenger); - const composableControllerMessenger = controllerMessenger.getRestricted({ - name: 'ComposableController', - allowedActions: [], - allowedEvents: [ - 'BarController:stateChange', - 'FooController:stateChange', - ], - }); - new ComposableController< - ComposableControllerState, - Pick - >({ - controllers: { - BarController: barController, - FooController: fooController, - }, - messenger: composableControllerMessenger, - }); - - const listener = sinon.stub(); - controllerMessenger.subscribe( - 'ComposableController:stateChange', - listener, - ); - fooController.updateFoo('bar'); - - expect(listener.calledOnce).toBe(true); - expect(listener.getCall(0).args[0]).toStrictEqual({ - BarController: { - bar: 'bar', - }, - FooController: { - foo: 'bar', - }, - }); + const fooController = new FooController(fooControllerMessenger); + const composableControllerMessenger = messenger.getRestricted({ + name: 'ComposableController', + allowedActions: [], + allowedEvents: ['QuzController:stateChange', 'FooController:stateChange'], + }); + new ComposableController< + ComposableControllerState, + Pick + >({ + controllers: { + QuzController: quzController, + FooController: fooController, + }, + messenger: composableControllerMessenger, }); - it('should throw if controller messenger not provided', () => { - const barController = new BarController(); - const controllerMessenger = new ControllerMessenger< - never, - FooControllerEvent - >(); - const fooControllerMessenger = controllerMessenger.getRestricted({ - name: 'FooController', - allowedActions: [], - allowedEvents: [], - }); - const fooController = new FooController(fooControllerMessenger); - expect( - () => - // @ts-expect-error - Suppressing type error to test for runtime error handling - new ComposableController({ - controllers: { - BarController: barController, - FooController: fooController, - }, - }), - ).toThrow('Messaging system is required'); + const listener = sinon.stub(); + messenger.subscribe('ComposableController:stateChange', listener); + fooController.updateFoo('qux'); + + expect(listener.calledOnce).toBe(true); + expect(listener.getCall(0).args[0]).toStrictEqual({ + QuzController: { + quz: 'quz', + }, + FooController: { + foo: 'qux', + }, }); + }); - it('should throw if composing a controller that does not extend from BaseController', () => { - type ComposableControllerState = { - FooController: FooControllerState; - }; - const notController = new JsonRpcEngine(); - const controllerMessenger = new ControllerMessenger< - never, - | ComposableControllerEvents - | FooControllerEvent - >(); - const fooControllerMessenger = controllerMessenger.getRestricted({ - name: 'FooController', - allowedActions: [], - allowedEvents: [], - }); - const fooController = new FooController(fooControllerMessenger); - const composableControllerMessenger = controllerMessenger.getRestricted({ - name: 'ComposableController', - allowedActions: [], - allowedEvents: ['FooController:stateChange'], - }); - expect( - () => - new ComposableController< - ComposableControllerState & { - JsonRpcEngine: Record; - }, - // @ts-expect-error - Suppressing type error to test for runtime error handling - { - JsonRpcEngine: typeof notController; - FooController: FooController; - } - >({ - controllers: { - JsonRpcEngine: notController, - FooController: fooController, - }, - messenger: composableControllerMessenger, - }), - ).toThrow(INVALID_CONTROLLER_ERROR); + it('should throw if controller messenger not provided', () => { + const messenger = new Messenger(); + const quzControllerMessenger = messenger.getRestricted({ + name: 'QuzController', + allowedActions: [], + allowedEvents: [], + }); + const quzController = new QuzController(quzControllerMessenger); + const fooControllerMessenger = messenger.getRestricted({ + name: 'FooController', + allowedActions: [], + allowedEvents: [], }); + const fooController = new FooController(fooControllerMessenger); + expect( + () => + // @ts-expect-error - Suppressing type error to test for runtime error handling + new ComposableController({ + controllers: { + QuzController: quzController, + FooController: fooController, + }, + }), + ).toThrow('Messaging system is required'); }); - it('should not throw if composing a controller without a `stateChange` event', () => { - const controllerMessenger = new ControllerMessenger< + it('should throw if composing a controller that does not extend from BaseController', () => { + type ComposableControllerState = { + FooController: FooControllerState; + }; + const notController = new JsonRpcEngine(); + const messenger = new Messenger< never, - FooControllerEvent + ComposableControllerEvents | FooControllerEvent >(); - const controllerWithoutStateChangeEventMessenger = - controllerMessenger.getRestricted({ - name: 'ControllerWithoutStateChangeEvent', - allowedActions: [], - allowedEvents: [], - }); + const fooControllerMessenger = messenger.getRestricted({ + name: 'FooController', + allowedActions: [], + allowedEvents: [], + }); + const fooController = new FooController(fooControllerMessenger); + const composableControllerMessenger = messenger.getRestricted({ + name: 'ComposableController', + allowedActions: [], + allowedEvents: ['FooController:stateChange'], + }); + expect( + () => + new ComposableController< + // @ts-expect-error - Suppressing type error to test for runtime error handling + ComposableControllerState & { + JsonRpcEngine: Record; + }, + { + JsonRpcEngine: typeof notController; + FooController: FooController; + } + >({ + controllers: { + JsonRpcEngine: notController, + FooController: fooController, + }, + messenger: composableControllerMessenger, + }), + ).toThrow(INVALID_CONTROLLER_ERROR); + }); + + it('should not throw if composing a controller without a `stateChange` event', () => { + const messenger = new Messenger(); + const controllerWithoutStateChangeEventMessenger = messenger.getRestricted({ + name: 'ControllerWithoutStateChangeEvent', + allowedActions: [], + allowedEvents: [], + }); const controllerWithoutStateChangeEvent = new ControllerWithoutStateChangeEvent( controllerWithoutStateChangeEventMessenger, ); - const fooControllerMessenger = controllerMessenger.getRestricted({ + const fooControllerMessenger = messenger.getRestricted({ name: 'FooController', allowedActions: [], allowedEvents: [], @@ -645,7 +395,7 @@ describe('ComposableController', () => { controllerWithoutStateChangeEvent, FooController: fooController, }, - messenger: controllerMessenger.getRestricted({ + messenger: messenger.getRestricted({ name: 'ComposableController', allowedActions: [], allowedEvents: ['FooController:stateChange'], @@ -655,17 +405,17 @@ describe('ComposableController', () => { }); it('should not throw if a child controller `stateChange` event is missing from the messenger events allowlist', () => { - const controllerMessenger = new ControllerMessenger< + const messenger = new Messenger< never, FooControllerEvent | QuzControllerEvent >(); - const QuzControllerMessenger = controllerMessenger.getRestricted({ + const QuzControllerMessenger = messenger.getRestricted({ name: 'QuzController', allowedActions: [], allowedEvents: [], }); const quzController = new QuzController(QuzControllerMessenger); - const fooControllerMessenger = controllerMessenger.getRestricted({ + const fooControllerMessenger = messenger.getRestricted({ name: 'FooController', allowedActions: [], allowedEvents: [], @@ -678,7 +428,7 @@ describe('ComposableController', () => { QuzController: quzController, FooController: fooController, }, - messenger: controllerMessenger.getRestricted({ + messenger: messenger.getRestricted({ name: 'ComposableController', allowedActions: [], allowedEvents: ['FooController:stateChange'], diff --git a/packages/composable-controller/src/ComposableController.ts b/packages/composable-controller/src/ComposableController.ts index 6a9e2d40930..fa1977c897d 100644 --- a/packages/composable-controller/src/ComposableController.ts +++ b/packages/composable-controller/src/ComposableController.ts @@ -1,58 +1,23 @@ import type { - RestrictedControllerMessenger, + RestrictedMessenger, StateConstraint, - StateConstraintV1, StateMetadata, StateMetadataConstraint, ControllerStateChangeEvent, - LegacyControllerStateConstraint, - ControllerInstance, + BaseControllerInstance as ControllerInstance, } from '@metamask/base-controller'; -import { - BaseController, - isBaseController, - isBaseControllerV1, -} from '@metamask/base-controller'; -import type { Patch } from 'immer'; +import { BaseController, isBaseController } from '@metamask/base-controller'; export const controllerName = 'ComposableController'; export const INVALID_CONTROLLER_ERROR = - 'Invalid controller: controller must have a `messagingSystem` or be a class inheriting from `BaseControllerV1`.'; - -/** - * A universal supertype for the composable controller state object. - * - * This type is only intended to be used for disabling the generic constraint on the `ControllerState` type argument in the `BaseController` type as a temporary solution for ensuring compatibility with BaseControllerV1 child controllers. - * Note that it is unsuitable for general use as a type constraint. - */ -// TODO: Replace with `ComposableControllerStateConstraint` once BaseControllerV2 migrations are completed for all controllers. -type LegacyComposableControllerStateConstraint = { - // `any` is used here to disable the generic constraint on the `ControllerState` type argument in the `BaseController` type, - // enabling composable controller state types with BaseControllerV1 state objects to be. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [name: string]: Record; -}; + 'Invalid controller: controller must have a `messagingSystem` and inherit from `BaseController`.'; /** * The narrowest supertype for the composable controller state object. - * This is also a widest subtype of the 'LegacyComposableControllerStateConstraint' type. */ -// TODO: Replace with `{ [name: string]: StateConstraint }` once BaseControllerV2 migrations are completed for all controllers. export type ComposableControllerStateConstraint = { - [name: string]: LegacyControllerStateConstraint; -}; - -/** - * A `stateChange` event for any controller instance that extends from either `BaseControllerV1` or `BaseControllerV2`. - */ -// TODO: Replace all instances with `ControllerStateChangeEvent` once `BaseControllerV2` migrations are completed for all controllers. -type LegacyControllerStateChangeEvent< - ControllerName extends string, - ControllerState extends StateConstraintV1, -> = { - type: `${ControllerName}:stateChange`; - payload: [ControllerState, Patch[]]; + [controllerName: string]: StateConstraint; }; /** @@ -62,7 +27,7 @@ type LegacyControllerStateChangeEvent< */ export type ComposableControllerStateChangeEvent< ComposableControllerState extends ComposableControllerStateConstraint, -> = LegacyControllerStateChangeEvent< +> = ControllerStateChangeEvent< typeof controllerName, ComposableControllerState >; @@ -80,23 +45,19 @@ export type ComposableControllerEvents< * A utility type that extracts controllers from the {@link ComposableControllerState} type, * and derives a union type of all of their corresponding `stateChange` events. * - * This type can handle both `BaseController` and `BaseControllerV1` controller instances. - * * @template ComposableControllerState - A type object that maps controller names to their state types. */ export type ChildControllerStateChangeEvents< ComposableControllerState extends ComposableControllerStateConstraint, -> = ComposableControllerState extends Record< - infer ControllerName extends string, - infer ControllerState -> - ? ControllerState extends StateConstraint - ? ControllerStateChangeEvent - : // TODO: Remove this conditional branch once `BaseControllerV2` migrations are completed for all controllers. - ControllerState extends StateConstraintV1 - ? LegacyControllerStateChangeEvent - : never - : never; +> = + ComposableControllerState extends Record< + infer ControllerName extends string, + infer ControllerState + > + ? ControllerState extends StateConstraint + ? ControllerStateChangeEvent + : never + : never; /** * A union type of external event types available to the {@link ComposableControllerMessenger}. @@ -114,7 +75,7 @@ export type AllowedEvents< */ export type ComposableControllerMessenger< ComposableControllerState extends ComposableControllerStateConstraint, -> = RestrictedControllerMessenger< +> = RestrictedMessenger< typeof controllerName, never, | ComposableControllerEvents @@ -130,7 +91,7 @@ export type ComposableControllerMessenger< * @template ChildControllersMap - A type object that specifies the child controllers which are used to instantiate the {@link ComposableController}. */ export class ComposableController< - ComposableControllerState extends LegacyComposableControllerStateConstraint, + ComposableControllerState extends ComposableControllerStateConstraint, ChildControllersMap extends Record< keyof ComposableControllerState, ControllerInstance @@ -145,7 +106,7 @@ export class ComposableController< * * @param options - Initial options used to configure this controller * @param options.controllers - An object that contains child controllers keyed by their names. - * @param options.messenger - A restricted controller messenger. + * @param options.messenger - A restricted messenger. */ constructor({ controllers, @@ -195,7 +156,7 @@ export class ComposableController< */ #updateChildController(controller: ControllerInstance): void { const { name } = controller; - if (!isBaseController(controller) && !isBaseControllerV1(controller)) { + if (!isBaseController(controller)) { try { delete this.metadata[name]; delete this.state[name]; @@ -210,9 +171,10 @@ export class ComposableController< // False negative. `name` is a string type. // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `${name}:stateChange`, - (childState: LegacyControllerStateConstraint) => { + (childState: StateConstraint) => { this.update((state) => { // Type assertion is necessary for property assignment to a generic type. This does not pollute or widen the type of the asserted variable. + // @ts-expect-error "Type instantiation is excessively deep" (state as ComposableControllerStateConstraint)[name] = childState; }); }, @@ -222,14 +184,6 @@ export class ComposableController< // eslint-disable-next-line @typescript-eslint/restrict-template-expressions console.error(`${name} - ${String(error)}`); } - if (isBaseControllerV1(controller)) { - controller.subscribe((childState: StateConstraintV1) => { - this.update((state) => { - // Type assertion is necessary for property assignment to a generic type. This does not pollute or widen the type of the asserted variable. - (state as ComposableControllerStateConstraint)[name] = childState; - }); - }); - } } } diff --git a/packages/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md index 8d5452c1495..0e9c9f5e7b5 100644 --- a/packages/controller-utils/CHANGELOG.md +++ b/packages/controller-utils/CHANGELOG.md @@ -7,9 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [11.5.0] + ### Added -- Add `createServicePolicy` function to assist with reducing boilerplate for service classes ([#5154](https://github.com/MetaMask/core/pull/5154), [#5143](https://github.com/MetaMask/core/pull/5143)) +- Add utility function `createServicePolicy` for reducing boilerplate for service classes ([#5141](https://github.com/MetaMask/core/pull/5141), [#5154](https://github.com/MetaMask/core/pull/5154), [#5143](https://github.com/MetaMask/core/pull/5143), [#5149](https://github.com/MetaMask/core/pull/5149), [#5188](https://github.com/MetaMask/core/pull/5188), [#5192](https://github.com/MetaMask/core/pull/5192), [#5225](https://github.com/MetaMask/core/pull/5225)) + - Export constants `DEFAULT_CIRCUIT_BREAK_DURATION`, `DEFAULT_DEGRADED_THRESHOLD`, `DEFAULT_MAX_CONSECUTIVE_FAILURES`, and `DEFAULT_MAX_RETRIES` + - Export types `ServicePolicy` and `CreateServicePolicyOptions` + - Re-export `BrokenCircuitError`, `CircuitState`, `handleAll`, and `handleWhen` from `cockatiel` + - Export `CockatielEvent` type, an alias of the `Event` type from `cockatiel` + +### Changed + +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) ## [11.4.5] @@ -448,7 +458,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/controller-utils@11.4.5...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.5.0...HEAD +[11.5.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.4.5...@metamask/controller-utils@11.5.0 [11.4.5]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.4.4...@metamask/controller-utils@11.4.5 [11.4.4]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.4.3...@metamask/controller-utils@11.4.4 [11.4.3]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.4.2...@metamask/controller-utils@11.4.3 diff --git a/packages/controller-utils/jest.config.js b/packages/controller-utils/jest.config.js index 746ae98e6f5..26df423f661 100644 --- a/packages/controller-utils/jest.config.js +++ b/packages/controller-utils/jest.config.js @@ -18,7 +18,7 @@ module.exports = merge(baseConfig, { coverageThreshold: { global: { branches: 78.12, - functions: 84.61, + functions: 80.35, lines: 87.3, statements: 86.5, }, diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json index 4cc3ceb4bf2..f40c65dd1b5 100644 --- a/packages/controller-utils/package.json +++ b/packages/controller-utils/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/controller-utils", - "version": "11.4.5", + "version": "11.5.0", "description": "Data and convenience functions shared by multiple packages", "keywords": [ "MetaMask", @@ -50,7 +50,7 @@ "@ethereumjs/util": "^8.1.0", "@metamask/eth-query": "^4.0.0", "@metamask/ethjs-unit": "^0.3.0", - "@metamask/utils": "^11.0.1", + "@metamask/utils": "^11.1.0", "@spruceid/siwe-parser": "2.1.0", "@types/bn.js": "^5.1.5", "bignumber.js": "^9.1.2", diff --git a/packages/controller-utils/src/create-service-policy.test.ts b/packages/controller-utils/src/create-service-policy.test.ts index 800023b5e25..0b5894dad8c 100644 --- a/packages/controller-utils/src/create-service-policy.test.ts +++ b/packages/controller-utils/src/create-service-policy.test.ts @@ -1,3 +1,7 @@ +// We use conditions exclusively in this file. +/* eslint-disable jest/no-conditional-in-test */ + +import { handleWhen } from 'cockatiel'; import { useFakeTimers } from 'sinon'; import type { SinonFakeTimers } from 'sinon'; @@ -39,58 +43,62 @@ describe('createServicePolicy', () => { expect(mockService).toHaveBeenCalledTimes(1); }); - it('does not call the onBreak callback, since the circuit never opens', async () => { + it('does not call the listener passed to onBreak, since the circuit never opens', async () => { const mockService = jest.fn(() => ({ some: 'data' })); - const onBreak = jest.fn(); - const policy = createServicePolicy({ onBreak }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy(); + policy.onBreak(onBreakListener); await policy.execute(mockService); - expect(onBreak).not.toHaveBeenCalled(); + expect(onBreakListener).not.toHaveBeenCalled(); }); describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { + it('does not call the listener passed to onDegraded if the service execution time is below the threshold', async () => { const mockService = jest.fn(() => ({ some: 'data' })); - const onDegraded = jest.fn(); - const policy = createServicePolicy({ onDegraded }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy(); + policy.onDegraded(onDegradedListener); await policy.execute(mockService); - expect(onDegraded).not.toHaveBeenCalled(); + expect(onDegradedListener).not.toHaveBeenCalled(); }); - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { + it('calls the listener passed to onDegraded once if the service execution time is beyond the threshold', async () => { const delay = DEFAULT_DEGRADED_THRESHOLD + 1; const mockService = jest.fn(() => { return new Promise((resolve) => { setTimeout(() => resolve({ some: 'data' }), delay); }); }); - const onDegraded = jest.fn(); - const policy = createServicePolicy({ onDegraded }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy(); + policy.onDegraded(onDegradedListener); const promise = policy.execute(mockService); clock.tick(delay); await promise; - expect(onDegraded).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledTimes(1); }); }); describe('using a custom degraded threshold', () => { - it('does not call the onDegraded callback if the service execution time below the threshold', async () => { + it('does not call the listener passed to onDegraded if the service execution time below the threshold', async () => { const degradedThreshold = 2000; const mockService = jest.fn(() => ({ some: 'data' })); - const onDegraded = jest.fn(); - const policy = createServicePolicy({ degradedThreshold, onDegraded }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ degradedThreshold }); + policy.onDegraded(onDegradedListener); await policy.execute(mockService); - expect(onDegraded).not.toHaveBeenCalled(); + expect(onDegradedListener).not.toHaveBeenCalled(); }); - it('calls the onDegraded callback once if the service execution time beyond the threshold', async () => { + it('calls the listener passed to onDegraded once if the service execution time beyond the threshold', async () => { const degradedThreshold = 2000; const delay = degradedThreshold + 1; const mockService = jest.fn(() => { @@ -98,80 +106,85 @@ describe('createServicePolicy', () => { setTimeout(() => resolve({ some: 'data' }), delay); }); }); - const onDegraded = jest.fn(); - const policy = createServicePolicy({ degradedThreshold, onDegraded }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ degradedThreshold }); + policy.onDegraded(onDegradedListener); const promise = policy.execute(mockService); clock.tick(delay); await promise; - expect(onDegraded).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledTimes(1); }); }); }); describe('wrapping a service that always fails', () => { - it(`calls the service a total of ${ - 1 + DEFAULT_MAX_RETRIES - } times, delaying each retry using a backoff formula`, async () => { - const error = new Error('failure'); - const mockService = jest.fn(() => { - throw error; - }); - const policy = createServicePolicy(); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue is - // enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await ignoreRejection(promise); + describe('if a custom retry filter policy is given and the retry filter policy filters out the thrown error', () => { + it('throws what the service throws', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message !== 'failure', + ), + }); - expect(mockService).toHaveBeenCalledTimes(1 + DEFAULT_MAX_RETRIES); - }); + const promise = policy.execute(mockService); - it('calls the onRetry callback once per retry', async () => { - const error = new Error('failure'); - const mockService = jest.fn(() => { - throw error; + await expect(promise).rejects.toThrow(error); }); - const onRetry = jest.fn(); - const policy = createServicePolicy({ onRetry }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue is - // enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await ignoreRejection(promise); + it('calls the service once and only once', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message !== 'failure', + ), + }); - expect(onRetry).toHaveBeenCalledTimes(DEFAULT_MAX_RETRIES); - }); + const promise = policy.execute(mockService); + await ignoreRejection(promise); - describe(`using the default max number of consecutive failures (${DEFAULT_MAX_CONSECUTIVE_FAILURES})`, () => { - it('throws what the service throws', async () => { + expect(mockService).toHaveBeenCalledTimes(1); + }); + + it('does not call the listener passed to onRetry', async () => { const error = new Error('failure'); const mockService = jest.fn(() => { throw error; }); - const policy = createServicePolicy(); + const onRetryListener = jest.fn(); + const policy = createServicePolicy({ + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message !== 'failure', + ), + }); + policy.onRetry(onRetryListener); const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); + await ignoreRejection(promise); - await expect(promise).rejects.toThrow(error); + expect(onRetryListener).not.toHaveBeenCalled(); }); - it('does not call the onBreak callback, since the max number of consecutive failures is never reached', async () => { + it('does not call the listener passed to onBreak', async () => { const error = new Error('failure'); const mockService = jest.fn(() => { throw error; }); - const onBreak = jest.fn(); - const policy = createServicePolicy({ onBreak }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message !== 'failure', + ), + }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise queue @@ -180,16 +193,21 @@ describe('createServicePolicy', () => { clock.runAllAsync(); await ignoreRejection(promise); - expect(onBreak).not.toHaveBeenCalled(); + expect(onBreakListener).not.toHaveBeenCalled(); }); - it('calls the onDegraded callback once, since the circuit is still closed', async () => { + it('does not call the listener passed to onDegraded', async () => { const error = new Error('failure'); const mockService = jest.fn(() => { throw error; }); - const onDegraded = jest.fn(); - const policy = createServicePolicy({ onDegraded }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message !== 'failure', + ), + }); + policy.onDegraded(onDegradedListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise queue @@ -198,444 +216,889 @@ describe('createServicePolicy', () => { clock.runAllAsync(); await ignoreRejection(promise); - expect(onDegraded).toHaveBeenCalledTimes(1); + expect(onDegradedListener).not.toHaveBeenCalled(); }); }); - describe('using a custom max number of consecutive failures', () => { - describe('if the initial run + retries is less than the max number of consecutive failures', () => { - it('throws what the service throws', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + describe('using the default retry filter policy (which retries all errors)', () => { + describe(`using the default max retries (${DEFAULT_MAX_RETRIES})`, () => { + it(`calls the service a total of ${ + 1 + DEFAULT_MAX_RETRIES + } times, delaying each retry using a backoff formula`, async () => { const error = new Error('failure'); const mockService = jest.fn(() => { throw error; }); - const onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, - }); + const policy = createServicePolicy(); + // Each retry delay is randomized using a decorrelated jitter formula, + // so we need to prevent that + jest.spyOn(Math, 'random').mockReturnValue(0); const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. + // It's safe not to await these promises; adding them to the promise + // queue is enough to prevent this test from running indefinitely. // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); + clock.tickAsync(0); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(176.27932892814937); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(186.8886145345685); + await ignoreRejection(promise); - await expect(promise).rejects.toThrow(error); + expect(mockService).toHaveBeenCalledTimes(1 + DEFAULT_MAX_RETRIES); }); - it('does not call the onBreak callback', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + it('calls the listener passed to onRetry once per retry', async () => { const error = new Error('failure'); const mockService = jest.fn(() => { throw error; }); - const onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, - }); + const onRetryListener = jest.fn(); + const policy = createServicePolicy(); + policy.onRetry(onRetryListener); const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. + // It's safe not to await this promise; adding it to the promise queue is + // enough to prevent this test from running indefinitely. // eslint-disable-next-line @typescript-eslint/no-floating-promises clock.runAllAsync(); await ignoreRejection(promise); - expect(onBreak).not.toHaveBeenCalled(); + expect(onRetryListener).toHaveBeenCalledTimes(DEFAULT_MAX_RETRIES); }); - it('calls the onDegraded callback once', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; - const error = new Error('failure'); - const mockService = jest.fn(() => { - throw error; - }); - const onDegraded = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onDegraded, + describe(`using the default max number of consecutive failures (${DEFAULT_MAX_CONSECUTIVE_FAILURES})`, () => { + it('throws what the service throws', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy(); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + + await expect(promise).rejects.toThrow(error); }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await ignoreRejection(promise); + it('does not call the listener passed to onBreak, since the max number of consecutive failures is never reached', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy(); + policy.onBreak(onBreakListener); - expect(onDegraded).toHaveBeenCalledTimes(1); - }); - }); + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); - describe('if the initial run + retries is equal to the max number of consecutive failures', () => { - it('throws what the service throws', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; - const error = new Error('failure'); - const mockService = jest.fn(() => { - throw error; - }); - const policy = createServicePolicy({ - maxConsecutiveFailures, + expect(onBreakListener).not.toHaveBeenCalled(); }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); + it('calls the listener passed to onDegraded once, since the circuit is still closed', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy(); + policy.onDegraded(onDegradedListener); - await expect(promise).rejects.toThrow(error); - }); + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); - it('calls the onBreak callback once with the error', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; - const error = new Error('failure'); - const mockService = jest.fn(() => { - throw error; - }); - const onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, + expect(onDegradedListener).toHaveBeenCalledTimes(1); }); + }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await ignoreRejection(promise); + describe('using a custom max number of consecutive failures', () => { + describe('if the initial run + retries is less than the max number of consecutive failures', () => { + it('throws what the service throws', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); - expect(onBreak).toHaveBeenCalledTimes(1); - expect(onBreak).toHaveBeenCalledWith({ error }); - }); + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); - it('never calls the onDegraded callback, since the circuit is open', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; - const error = new Error('failure'); - const mockService = jest.fn(() => { - throw error; - }); - const onDegraded = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onDegraded, - }); + await expect(promise).rejects.toThrow(error); + }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await ignoreRejection(promise); + it('does not call the listener passed to onBreak', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onBreak(onBreakListener); - expect(onDegraded).not.toHaveBeenCalled(); - }); + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); - it('throws a BrokenCircuitError instead of whatever error the service produces if the service is executed again', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; - const error = new Error('failure'); - const mockService = jest.fn(() => { - throw error; - }); - const policy = createServicePolicy({ - maxConsecutiveFailures, - }); + expect(onBreakListener).not.toHaveBeenCalled(); + }); - const firstExecution = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await ignoreRejection(firstExecution); - - const secondExecution = policy.execute(mockService); - await expect(secondExecution).rejects.toThrow( - new Error( - 'Execution prevented because the circuit breaker is open', - ), - ); - }); - }); + it('calls the listener passed to onDegraded once', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onDegraded(onDegradedListener); - describe('if the initial run + retries is greater than the max number of consecutive failures', () => { - it('throws a BrokenCircuitError instead of whatever error the service produces', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; - const error = new Error('failure'); - const mockService = jest.fn(() => { - throw error; + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); }); - const policy = createServicePolicy({ - maxConsecutiveFailures, + + describe('if the initial run + retries is equal to the max number of consecutive failures', () => { + it('throws what the service throws', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + + await expect(promise).rejects.toThrow(error); + }); + + it('calls the listener passed to onBreak once with the error', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ error }); + }); + + it('never calls the listener passed to onDegraded, since the circuit is open', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('throws a BrokenCircuitError instead of whatever error the service produces if the service is executed again', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + + const firstExecution = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(firstExecution); + + const secondExecution = policy.execute(mockService); + await expect(secondExecution).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + }); }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); + describe('if the initial run + retries is greater than the max number of consecutive failures', () => { + it('throws a BrokenCircuitError instead of whatever error the service produces', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + + await expect(promise).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + }); + + it('calls the listener passed to onBreak once with the error', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ error }); + }); + + it('never calls the listener passed to onDegraded, since the circuit is open', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onDegraded(onDegradedListener); - await expect(promise).rejects.toThrow( - new Error( - 'Execution prevented because the circuit breaker is open', - ), - ); + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + }); }); + }); - it('calls the onBreak callback once with the error', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + describe('using a custom max number of retries', () => { + it(`calls the service a total of 1 + times, delaying each retry using a backoff formula`, async () => { + const maxRetries = 5; const error = new Error('failure'); const mockService = jest.fn(() => { throw error; }); - const onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, - }); + const policy = createServicePolicy({ maxRetries }); + // Each retry delay is randomized using a decorrelated jitter formula, + // so we need to prevent that + jest.spyOn(Math, 'random').mockReturnValue(0); const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. + // It's safe not to await these promises; adding them to the promise + // queue is enough to prevent this test from running indefinitely. // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); + clock.tickAsync(0); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(176.27932892814937); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(186.8886145345685); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(366.8287823691078); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(731.8792783578953); await ignoreRejection(promise); - expect(onBreak).toHaveBeenCalledTimes(1); - expect(onBreak).toHaveBeenCalledWith({ error }); + expect(mockService).toHaveBeenCalledTimes(1 + maxRetries); }); - it('never calls the onDegraded callback, since the circuit is open', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + it('calls the onRetry callback once per retry', async () => { + const maxRetries = 5; const error = new Error('failure'); const mockService = jest.fn(() => { throw error; }); - const onDegraded = jest.fn(); + const onRetryListener = jest.fn(); const policy = createServicePolicy({ - maxConsecutiveFailures, - onDegraded, + maxRetries, }); + policy.onRetry(onRetryListener); const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. + // It's safe not to await this promise; adding it to the promise queue is + // enough to prevent this test from running indefinitely. // eslint-disable-next-line @typescript-eslint/no-floating-promises clock.runAllAsync(); await ignoreRejection(promise); - expect(onDegraded).not.toHaveBeenCalled(); + expect(onRetryListener).toHaveBeenCalledTimes(maxRetries); }); - }); - }); - }); - describe('wrapping a service that fails continuously and then succeeds on the final try', () => { - it(`calls the service a total of ${ - 1 + DEFAULT_MAX_RETRIES - } times, delaying each retry using a backoff formula`, async () => { - let invocationCounter = 0; - const mockService = jest.fn(() => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); - }); - const policy = createServicePolicy(); + describe(`using the default max number of consecutive failures (${DEFAULT_MAX_CONSECUTIVE_FAILURES})`, () => { + describe('if the initial run + retries is less than the max number of consecutive failures', () => { + it('throws what the service throws', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ maxRetries }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue is - // enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); - expect(mockService).toHaveBeenCalledTimes(1 + DEFAULT_MAX_RETRIES); - }); + await expect(promise).rejects.toThrow(error); + }); - it('calls the onRetry callback once per retry', async () => { - let invocationCounter = 0; - const mockService = jest.fn(() => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); - }); - const onRetry = jest.fn(); - const policy = createServicePolicy({ onRetry }); + it('does not call the onBreak callback', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onBreakListener).not.toHaveBeenCalled(); + }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue is - // enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; + it('calls the onDegraded callback once', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + }); - expect(onRetry).toHaveBeenCalledTimes(DEFAULT_MAX_RETRIES); - }); + describe('if the initial run + retries is equal to the max number of consecutive failures', () => { + it('throws what the service throws', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ maxRetries }); - describe(`using the default max number of consecutive failures (${DEFAULT_MAX_CONSECUTIVE_FAILURES})`, () => { - it('returns what the service returns', async () => { - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); - }; - const onBreak = jest.fn(); - const policy = createServicePolicy({ onBreak }); + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); + await expect(promise).rejects.toThrow(error); + }); - expect(await promise).toStrictEqual({ some: 'data' }); - }); + it('calls the onBreak callback once with the error', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ error }); + }); - it('does not call the onBreak callback, since the max number of consecutive failures is never reached', async () => { - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); - }; - const onBreak = jest.fn(); - const policy = createServicePolicy({ onBreak }); + it('never calls the onDegraded callback, since the circuit is open', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; + it('throws a BrokenCircuitError instead of whatever error the service produces if the policy is executed again', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ maxRetries }); + + const firstExecution = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(firstExecution); + + const secondExecution = policy.execute(mockService); + await expect(secondExecution).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + }); + }); - expect(onBreak).not.toHaveBeenCalled(); - }); - - describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); - }; - const onDegraded = jest.fn(); - const policy = createServicePolicy({ onDegraded }); + describe('if the initial run + retries is greater than the max number of consecutive failures', () => { + it('throws a BrokenCircuitError instead of whatever error the service produces', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; + const mockService = jest.fn(() => { + throw new Error('failure'); + }); + const policy = createServicePolicy({ maxRetries }); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + + await expect(promise).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; + it('calls the onBreak callback once with the error', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ error }); + }); - expect(onDegraded).not.toHaveBeenCalled(); + it('never calls the onDegraded callback, since the circuit is open', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + }); }); - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - let invocationCounter = 0; - const delay = DEFAULT_DEGRADED_THRESHOLD + 1; - const mockService = () => { - invocationCounter += 1; - return new Promise((resolve, reject) => { - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); - } + describe('using a custom max number of consecutive failures', () => { + describe('if the initial run + retries is less than the max number of consecutive failures', () => { + it('throws what the service throws', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + + await expect(promise).rejects.toThrow(error); }); - }; - const onDegraded = jest.fn(); - const policy = createServicePolicy({ onDegraded }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; + it('does not call the onBreak callback', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onBreak(onBreakListener); - expect(onDegraded).toHaveBeenCalledTimes(1); - }); - }); + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); - describe('using a custom degraded threshold', () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const degradedThreshold = 2000; - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); - }; - const onDegraded = jest.fn(); - const policy = createServicePolicy({ - onDegraded, - degradedThreshold, + expect(onBreakListener).not.toHaveBeenCalled(); + }); + + it('calls the onDegraded callback once', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; + describe('if the initial run + retries is equal to the max number of consecutive failures', () => { + it('throws what the service throws', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); - expect(onDegraded).not.toHaveBeenCalled(); - }); + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const degradedThreshold = 2000; - let invocationCounter = 0; - const delay = degradedThreshold + 1; - const mockService = () => { - invocationCounter += 1; - return new Promise((resolve, reject) => { - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); - } + await expect(promise).rejects.toThrow(error); + }); + + it('calls the onBreak callback once with the error', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ error }); + }); + + it('never calls the onDegraded callback, since the circuit is open', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('throws a BrokenCircuitError instead of whatever error the service produces if the policy is executed again', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + + const firstExecution = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(firstExecution); + + const secondExecution = policy.execute(mockService); + await expect(secondExecution).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); }); - }; - const onDegraded = jest.fn(); - const policy = createServicePolicy({ - onDegraded, - degradedThreshold, }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; + describe('if the initial run + retries is greater than the max number of consecutive failures', () => { + it('throws a BrokenCircuitError instead of whatever error the service produces', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + + await expect(promise).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + }); + + it('calls the onBreak callback once with the error', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ error }); + }); + + it('never calls the onDegraded callback, since the circuit is open', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); - expect(onDegraded).toHaveBeenCalledTimes(1); + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + }); }); }); }); + }); + + describe('wrapping a service that fails continuously and then succeeds on the final try', () => { + // NOTE: Using a custom retry filter policy is not tested here since the + // same thing would happen as above if the error is filtered out + + describe(`using the default max retries (${DEFAULT_MAX_RETRIES})`, () => { + it(`calls the service a total of ${ + 1 + DEFAULT_MAX_RETRIES + } times, delaying each retry using a backoff formula`, async () => { + let invocationCounter = 0; + const mockService = jest.fn(() => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const policy = createServicePolicy(); + // Each retry delay is randomized using a decorrelated jitter formula, + // so we need to prevent that + jest.spyOn(Math, 'random').mockReturnValue(0); + + const promise = policy.execute(mockService); + // It's safe not to await these promises; adding them to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(0); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(176.27932892814937); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(186.8886145345685); + await promise; + + expect(mockService).toHaveBeenCalledTimes(1 + DEFAULT_MAX_RETRIES); + }); - describe('using a custom max number of consecutive failures', () => { - describe('if the initial run + retries is less than the max number of consecutive failures', () => { + describe(`using the default max number of consecutive failures (${DEFAULT_MAX_CONSECUTIVE_FAILURES})`, () => { it('returns what the service returns', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; let invocationCounter = 0; const mockService = () => { invocationCounter += 1; @@ -644,11 +1107,9 @@ describe('createServicePolicy', () => { } throw new Error('failure'); }; - const onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, - }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy(); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise queue @@ -659,8 +1120,7 @@ describe('createServicePolicy', () => { expect(await promise).toStrictEqual({ some: 'data' }); }); - it('does not call the onBreak callback', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + it('does not call the onBreak callback, since the max number of consecutive failures is never reached', async () => { let invocationCounter = 0; const mockService = () => { invocationCounter += 1; @@ -669,11 +1129,9 @@ describe('createServicePolicy', () => { } throw new Error('failure'); }; - const onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, - }); + const onBreakListener = jest.fn(); + const policy = createServicePolicy(); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise queue @@ -682,12 +1140,11 @@ describe('createServicePolicy', () => { clock.runAllAsync(); await promise; - expect(onBreak).not.toHaveBeenCalled(); + expect(onBreakListener).not.toHaveBeenCalled(); }); describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; let invocationCounter = 0; const mockService = () => { invocationCounter += 1; @@ -696,11 +1153,9 @@ describe('createServicePolicy', () => { } throw new Error('failure'); }; - const onDegraded = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onDegraded, - }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy(); + policy.onDegraded(onDegradedListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise @@ -709,13 +1164,12 @@ describe('createServicePolicy', () => { clock.runAllAsync(); await promise; - expect(onDegraded).not.toHaveBeenCalled(); + expect(onDegradedListener).not.toHaveBeenCalled(); }); it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; - const delay = DEFAULT_DEGRADED_THRESHOLD + 1; let invocationCounter = 0; + const delay = DEFAULT_DEGRADED_THRESHOLD + 1; const mockService = () => { invocationCounter += 1; return new Promise((resolve, reject) => { @@ -726,11 +1180,9 @@ describe('createServicePolicy', () => { } }); }; - const onDegraded = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onDegraded, - }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy(); + policy.onDegraded(onDegradedListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise @@ -739,14 +1191,13 @@ describe('createServicePolicy', () => { clock.runAllAsync(); await promise; - expect(onDegraded).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledTimes(1); }); }); describe('using a custom degraded threshold', () => { it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { const degradedThreshold = 2000; - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; let invocationCounter = 0; const mockService = () => { invocationCounter += 1; @@ -755,12 +1206,11 @@ describe('createServicePolicy', () => { } throw new Error('failure'); }; - const onDegraded = jest.fn(); + const onDegradedListener = jest.fn(); const policy = createServicePolicy({ - maxConsecutiveFailures, - onDegraded, degradedThreshold, }); + policy.onDegraded(onDegradedListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise @@ -769,14 +1219,13 @@ describe('createServicePolicy', () => { clock.runAllAsync(); await promise; - expect(onDegraded).not.toHaveBeenCalled(); + expect(onDegradedListener).not.toHaveBeenCalled(); }); it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { const degradedThreshold = 2000; - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; - const delay = degradedThreshold + 1; let invocationCounter = 0; + const delay = degradedThreshold + 1; const mockService = () => { invocationCounter += 1; return new Promise((resolve, reject) => { @@ -787,12 +1236,11 @@ describe('createServicePolicy', () => { } }); }; - const onDegraded = jest.fn(); + const onDegradedListener = jest.fn(); const policy = createServicePolicy({ - maxConsecutiveFailures, - onDegraded, degradedThreshold, }); + policy.onDegraded(onDegradedListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise @@ -801,111 +1249,53 @@ describe('createServicePolicy', () => { clock.runAllAsync(); await promise; - expect(onDegraded).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledTimes(1); }); }); }); - describe('if the initial run + retries is equal to the max number of consecutive failures', () => { - it('returns what the service returns', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); - }; - const onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, - }); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - - expect(await promise).toStrictEqual({ some: 'data' }); - }); - - it('does not call the onBreak callback', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw error; - }; - const onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, - }); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onBreak).not.toHaveBeenCalled(); - }); - - describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + describe('using a custom max number of consecutive failures', () => { + describe('if the initial run + retries is less than the max number of consecutive failures', () => { + it('returns what the service returns', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; let invocationCounter = 0; - const error = new Error('failure'); const mockService = () => { invocationCounter += 1; if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { return { some: 'data' }; } - throw error; + throw new Error('failure'); }; - const onDegraded = jest.fn(); + const onBreakListener = jest.fn(); const policy = createServicePolicy({ maxConsecutiveFailures, - onDegraded, }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise // queue is enough to prevent this test from running indefinitely. // eslint-disable-next-line @typescript-eslint/no-floating-promises clock.runAllAsync(); - await promise; - expect(onDegraded).not.toHaveBeenCalled(); + expect(await promise).toStrictEqual({ some: 'data' }); }); - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; - const delay = DEFAULT_DEGRADED_THRESHOLD + 1; + it('does not call the onBreak callback', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; let invocationCounter = 0; const mockService = () => { invocationCounter += 1; - return new Promise((resolve, reject) => { - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); - } - }); + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw new Error('failure'); }; - const onDegraded = jest.fn(); + const onBreakListener = jest.fn(); const policy = createServicePolicy({ maxConsecutiveFailures, - onDegraded, }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise @@ -914,217 +1304,1503 @@ describe('createServicePolicy', () => { clock.runAllAsync(); await promise; - expect(onDegraded).toHaveBeenCalledTimes(1); + expect(onBreakListener).not.toHaveBeenCalled(); + }); + + describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { + it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw new Error('failure'); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + const delay = DEFAULT_DEGRADED_THRESHOLD + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('using a custom degraded threshold', () => { + it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { + const degradedThreshold = 2000; + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw new Error('failure'); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + degradedThreshold, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { + const degradedThreshold = 2000; + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + const delay = degradedThreshold + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + degradedThreshold, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); }); }); - describe('using a custom degraded threshold', () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const degradedThreshold = 2000; + describe('if the initial run + retries is equal to the max number of consecutive failures', () => { + it('returns what the service returns', async () => { const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; let invocationCounter = 0; - const error = new Error('failure'); const mockService = () => { invocationCounter += 1; if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { return { some: 'data' }; } - throw error; + throw new Error('failure'); }; - const onDegraded = jest.fn(); + const onBreakListener = jest.fn(); const policy = createServicePolicy({ maxConsecutiveFailures, - onDegraded, - degradedThreshold, }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise // queue is enough to prevent this test from running indefinitely. // eslint-disable-next-line @typescript-eslint/no-floating-promises clock.runAllAsync(); - await promise; - expect(onDegraded).not.toHaveBeenCalled(); + expect(await promise).toStrictEqual({ some: 'data' }); }); - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const degradedThreshold = 2000; + it('does not call the onBreak callback', async () => { const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; - const delay = degradedThreshold + 1; let invocationCounter = 0; + const error = new Error('failure'); const mockService = () => { invocationCounter += 1; - return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw error; + }; + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + + describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { + it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); + return { some: 'data' }; + } + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const delay = DEFAULT_DEGRADED_THRESHOLD + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('using a custom degraded threshold', () => { + it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { + const degradedThreshold = 2000; + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; } + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + degradedThreshold, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { + const degradedThreshold = 2000; + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const delay = degradedThreshold + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + degradedThreshold, }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('if the initial run + retries is greater than the max number of consecutive failures', () => { + it('throws a BrokenCircuitError before the service can succeed', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw error; }; - const onDegraded = jest.fn(); + const onBreakListener = jest.fn(); const policy = createServicePolicy({ maxConsecutiveFailures, - onDegraded, - degradedThreshold, }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise // queue is enough to prevent this test from running indefinitely. // eslint-disable-next-line @typescript-eslint/no-floating-promises clock.runAllAsync(); - await promise; + await expect(promise).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + }); + + it('calls the onBreak callback once with the error', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw error; + }; + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ error }); + }); + + it('does not call the onDegraded callback', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onDegraded(onDegradedListener); - expect(onDegraded).toHaveBeenCalledTimes(1); + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + describe(`using the default circuit break duration (${DEFAULT_CIRCUIT_BREAK_DURATION})`, () => { + it('returns what the service returns if it is successfully called again after the circuit break duration has elapsed', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw error; + }; + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + + const firstExecution = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(firstExecution); + clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); + const result = await policy.execute(mockService); + + expect(result).toStrictEqual({ some: 'data' }); + }); + }); + + describe('using a custom circuit break duration', () => { + it('returns what the service returns if it is successfully called again after the circuit break duration has elapsed', async () => { + // This has to be high enough to exceed the exponential backoff + const circuitBreakDuration = 5_000; + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw error; + }; + const policy = createServicePolicy({ + maxConsecutiveFailures, + circuitBreakDuration, + }); + + const firstExecution = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(firstExecution); + clock.tick(circuitBreakDuration); + const result = await policy.execute(mockService); + + expect(result).toStrictEqual({ some: 'data' }); + }); }); }); }); + }); - describe('if the initial run + retries is greater than the max number of consecutive failures', () => { - it('throws a BrokenCircuitError before the service can succeed', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw error; - }; - const onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, + describe('using a custom max number of retries', () => { + it(`calls the service a total of 1 + times, delaying each retry using a backoff formula`, async () => { + const maxRetries = 5; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = jest.fn(() => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }); + const policy = createServicePolicy({ maxRetries }); + // Each retry delay is randomized using a decorrelated jitter formula, + // so we need to prevent that + jest.spyOn(Math, 'random').mockReturnValue(0); + + const promise = policy.execute(mockService); + // It's safe not to await these promises; adding them to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(0); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(176.27932892814937); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(186.8886145345685); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(366.8287823691078); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(731.8792783578953); + await promise; + + expect(mockService).toHaveBeenCalledTimes(1 + maxRetries); + }); + + describe(`using the default max number of consecutive failures (${DEFAULT_MAX_CONSECUTIVE_FAILURES})`, () => { + describe('if the initial run + retries is less than the max number of consecutive failures', () => { + it('returns what the service returns', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const policy = createServicePolicy({ maxRetries }); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + + expect(await promise).toStrictEqual({ some: 'data' }); }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await expect(promise).rejects.toThrow( - new Error( - 'Execution prevented because the circuit breaker is open', - ), - ); + it('does not call the onBreak callback', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + + describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { + it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + const delay = DEFAULT_DEGRADED_THRESHOLD + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('using a custom degraded threshold', () => { + it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { + const degradedThreshold = 2000; + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + degradedThreshold, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { + const degradedThreshold = 2000; + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + const delay = degradedThreshold + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + degradedThreshold, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + }); }); - it('calls the onBreak callback once with the error', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw error; - }; - const onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, + describe('if the initial run + retries is equal to the max number of consecutive failures', () => { + it('returns what the service returns', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const policy = createServicePolicy({ maxRetries }); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + + expect(await promise).toStrictEqual({ some: 'data' }); }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await ignoreRejection(promise); + it('does not call the onBreak callback', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + + describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { + it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + const delay = DEFAULT_DEGRADED_THRESHOLD + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('using a custom degraded threshold', () => { + it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { + const degradedThreshold = 2000; + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + degradedThreshold, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { + const degradedThreshold = 2000; + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + const delay = degradedThreshold + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + degradedThreshold, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; - expect(onBreak).toHaveBeenCalledTimes(1); - expect(onBreak).toHaveBeenCalledWith({ error }); + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + }); }); - it('does not call the onDegraded callback', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw error; - }; - const onDegraded = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onDegraded, + describe('if the initial run + retries is greater than the max number of consecutive failures', () => { + it('throws a BrokenCircuitError before the service can succeed', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const policy = createServicePolicy({ maxRetries }); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + + await expect(promise).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise queue - // is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await ignoreRejection(promise); + it('calls the onBreak callback once with the error', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ error }); + }); + + it('does not call the onDegraded callback', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + describe(`using the default circuit break duration (${DEFAULT_CIRCUIT_BREAK_DURATION})`, () => { + it('returns what the service returns if it is successfully called again after the circuit break duration has elapsed', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const policy = createServicePolicy({ maxRetries }); + + const firstExecution = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(firstExecution); + clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); + const result = await policy.execute(mockService); + + expect(result).toStrictEqual({ some: 'data' }); + }); + }); - expect(onDegraded).not.toHaveBeenCalled(); + describe('using a custom circuit break duration', () => { + it('returns what the service returns if it is successfully called again after the circuit break duration has elapsed', async () => { + // This has to be high enough to exceed the exponential backoff + const circuitBreakDuration = 50_000; + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const policy = createServicePolicy({ + maxRetries, + circuitBreakDuration, + }); + + const firstExecution = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await expect(firstExecution).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + clock.tick(circuitBreakDuration); + const result = await policy.execute(mockService); + + expect(result).toStrictEqual({ some: 'data' }); + }); + }); }); + }); - describe(`using the default circuit break duration (${DEFAULT_CIRCUIT_BREAK_DURATION})`, () => { - it('returns what the service returns if it is successfully called again after the circuit break duration has elapsed', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + describe('using a custom max number of consecutive failures', () => { + describe('if the initial run + retries is less than the max number of consecutive failures', () => { + it('returns what the service returns', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; let invocationCounter = 0; const error = new Error('failure'); const mockService = () => { invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + + expect(await promise).toStrictEqual({ some: 'data' }); + }); + + it('does not call the onBreak callback', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + + describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { + it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; + const delay = DEFAULT_DEGRADED_THRESHOLD + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('using a custom degraded threshold', () => { + it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { + const degradedThreshold = 2000; + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + degradedThreshold, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { + const degradedThreshold = 2000; + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; + const delay = degradedThreshold + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + degradedThreshold, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('if the initial run + retries is equal to the max number of consecutive failures', () => { + it('returns what the service returns', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + + expect(await promise).toStrictEqual({ some: 'data' }); + }); + + it('does not call the onBreak callback', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { return { some: 'data' }; } throw error; }; + const onBreakListener = jest.fn(); const policy = createServicePolicy({ + maxRetries, maxConsecutiveFailures, }); + policy.onBreak(onBreakListener); - const firstExecution = policy.execute(mockService); + const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise // queue is enough to prevent this test from running indefinitely. // eslint-disable-next-line @typescript-eslint/no-floating-promises clock.runAllAsync(); - await ignoreRejection(firstExecution); - clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); - const result = await policy.execute(mockService); + await promise; + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + + describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { + it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + const delay = DEFAULT_DEGRADED_THRESHOLD + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('using a custom degraded threshold', () => { + it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { + const degradedThreshold = 2000; + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + degradedThreshold, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); - expect(result).toStrictEqual({ some: 'data' }); + it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { + const degradedThreshold = 2000; + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + const delay = degradedThreshold + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + degradedThreshold, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); }); }); - describe('using a custom circuit break duration', () => { - it('returns what the service returns if it is successfully called again after the circuit break duration has elapsed', async () => { - // This has to be high enough to exceed the exponential backoff - const circuitBreakDuration = 5_000; - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + describe('if the initial run + retries is greater than the max number of consecutive failures', () => { + it('throws a BrokenCircuitError before the service can succeed', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures; let invocationCounter = 0; const error = new Error('failure'); const mockService = () => { invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + await expect(promise).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + }); + + it('calls the onBreak callback once with the error', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ error }); + }); + + it('does not call the onDegraded callback', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { return { some: 'data' }; } throw error; }; + const onDegradedListener = jest.fn(); const policy = createServicePolicy({ + maxRetries, maxConsecutiveFailures, - circuitBreakDuration, }); + policy.onDegraded(onDegradedListener); - const firstExecution = policy.execute(mockService); + const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise // queue is enough to prevent this test from running indefinitely. // eslint-disable-next-line @typescript-eslint/no-floating-promises clock.runAllAsync(); - await ignoreRejection(firstExecution); - clock.tick(circuitBreakDuration); - const result = await policy.execute(mockService); + await ignoreRejection(promise); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + describe(`using the default circuit break duration (${DEFAULT_CIRCUIT_BREAK_DURATION})`, () => { + it('returns what the service returns if it is successfully called again after the circuit break duration has elapsed', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); - expect(result).toStrictEqual({ some: 'data' }); + const firstExecution = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(firstExecution); + clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); + const result = await policy.execute(mockService); + + expect(result).toStrictEqual({ some: 'data' }); + }); + }); + + describe('using a custom circuit break duration', () => { + it('returns what the service returns if it is successfully called again after the circuit break duration has elapsed', async () => { + // This has to be high enough to exceed the exponential backoff + const circuitBreakDuration = 5_000; + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + circuitBreakDuration, + }); + + const firstExecution = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await expect(firstExecution).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + clock.tick(circuitBreakDuration); + const result = await policy.execute(mockService); + + expect(result).toStrictEqual({ some: 'data' }); + }); }); }); }); diff --git a/packages/controller-utils/src/create-service-policy.ts b/packages/controller-utils/src/create-service-policy.ts index c985dba9e2d..f0943e20591 100644 --- a/packages/controller-utils/src/create-service-policy.ts +++ b/packages/controller-utils/src/create-service-policy.ts @@ -1,15 +1,93 @@ import { - circuitBreaker, + BrokenCircuitError, + CircuitState, + EventEmitter as CockatielEventEmitter, ConsecutiveBreaker, ExponentialBackoff, + circuitBreaker, handleAll, + handleWhen, retry, wrap, - CircuitState, } from 'cockatiel'; -import type { IPolicy } from 'cockatiel'; +import type { + CircuitBreakerPolicy, + Event as CockatielEvent, + IPolicy, + Policy, + RetryPolicy, +} from 'cockatiel'; + +export { CircuitState, BrokenCircuitError, handleAll, handleWhen }; -export type { IPolicy as IServicePolicy }; +export type { CockatielEvent }; + +/** + * The options for `createServicePolicy`. + */ +export type CreateServicePolicyOptions = { + /** + * The length of time (in milliseconds) to pause retries of the action after + * the number of failures reaches `maxConsecutiveFailures`. + */ + circuitBreakDuration?: number; + /** + * The length of time (in milliseconds) that governs when the service is + * regarded as degraded (affecting when `onDegraded` is called). + */ + degradedThreshold?: number; + /** + * The maximum number of times that the service is allowed to fail before + * pausing further retries. + */ + maxConsecutiveFailures?: number; + /** + * The maximum number of times that a failing service should be re-invoked + * before giving up. + */ + maxRetries?: number; + /** + * The policy used to control when the service should be retried based on + * either the result of the service or an error that it throws. For instance, + * you could use this to retry only certain errors. See `handleWhen` and + * friends from Cockatiel for more. + */ + retryFilterPolicy?: Policy; +}; + +/** + * The service policy object. + */ +export type ServicePolicy = IPolicy & { + /** + * The Cockatiel circuit breaker policy that the service policy uses + * internally. + */ + circuitBreakerPolicy: CircuitBreakerPolicy; + /** + * The Cockatiel retry policy that the service policy uses internally. + */ + retryPolicy: RetryPolicy; + /** + * A function which is called when the number of times that the service fails + * in a row meets the set maximum number of consecutive failures. + */ + onBreak: CircuitBreakerPolicy['onBreak']; + /** + * A function which is called in two circumstances: 1) when the service + * succeeds before the maximum number of consecutive failures is reached, but + * takes more time than the `degradedThreshold` to run, or 2) if the service + * never succeeds before the retry policy gives up and before the maximum + * number of consecutive failures has been reached. + */ + onDegraded: CockatielEvent; + /** + * A function which will be called by the retry policy each time the service + * fails and the policy kicks off a timer to re-run the service. This is + * primarily useful in tests where we are mocking timers. + */ + onRetry: RetryPolicy['onRetry']; +}; /** * The maximum number of times that a failing service should be re-run before @@ -19,7 +97,9 @@ export const DEFAULT_MAX_RETRIES = 3; /** * The maximum number of times that the service is allowed to fail before - * pausing further retries. + * pausing further retries. This is set to a value such that if given a + * service that continually fails, the policy needs to be executed 3 times + * before further retries are paused. */ export const DEFAULT_MAX_CONSECUTIVE_FAILURES = (1 + DEFAULT_MAX_RETRIES) * 3; @@ -51,6 +131,12 @@ export const DEFAULT_DEGRADED_THRESHOLD = 5_000; * there for more. * * @param options - The options to this function. + * @param options.maxRetries - The maximum number of times that a failing + * service should be re-invoked before giving up. Defaults to 3. + * @param options.retryFilterPolicy - The policy used to control when the + * service should be retried based on either the result of the servce or an + * error that it throws. For instance, you could use this to retry only certain + * errors. See `handleWhen` and friends from Cockatiel for more. * @param options.maxConsecutiveFailures - The maximum number of times that the * service is allowed to fail before pausing further retries. Defaults to 12. * @param options.circuitBreakDuration - The length of time (in milliseconds) to @@ -59,14 +145,6 @@ export const DEFAULT_DEGRADED_THRESHOLD = 5_000; * @param options.degradedThreshold - The length of time (in milliseconds) that * governs when the service is regarded as degraded (affecting when `onDegraded` * is called). Defaults to 5 seconds. - * @param options.onBreak - A function which is called when the service fails - * too many times in a row (specifically, more than `maxConsecutiveFailures`). - * @param options.onDegraded - A function which is called when the service - * succeeds before `maxConsecutiveFailures` is reached, but takes more time than - * the `degradedThreshold` to run. - * @param options.onRetry - A function which will be called the moment the - * policy kicks off a timer to re-run the function passed to the policy. This is - * primarily useful in tests where we are mocking timers. * @returns The service policy. * @example * This function is designed to be used in the context of a service class like @@ -75,6 +153,10 @@ export const DEFAULT_DEGRADED_THRESHOLD = 5_000; * class Service { * constructor() { * this.#policy = createServicePolicy({ + * maxRetries: 3, + * retryFilterPolicy: handleWhen((error) => { + * return error.message.includes('oops'); + * }), * maxConsecutiveFailures: 3, * circuitBreakDuration: 5000, * degradedThreshold: 2000, @@ -97,34 +179,21 @@ export const DEFAULT_DEGRADED_THRESHOLD = 5_000; * ``` */ export function createServicePolicy({ + maxRetries = DEFAULT_MAX_RETRIES, + retryFilterPolicy = handleAll, maxConsecutiveFailures = DEFAULT_MAX_CONSECUTIVE_FAILURES, circuitBreakDuration = DEFAULT_CIRCUIT_BREAK_DURATION, degradedThreshold = DEFAULT_DEGRADED_THRESHOLD, - onBreak = () => { - // do nothing - }, - onDegraded = () => { - // do nothing - }, - onRetry = () => { - // do nothing - }, -}: { - maxConsecutiveFailures?: number; - circuitBreakDuration?: number; - degradedThreshold?: number; - onBreak?: () => void; - onDegraded?: () => void; - onRetry?: () => void; -} = {}): IPolicy { - const retryPolicy = retry(handleAll, { +}: CreateServicePolicyOptions = {}): ServicePolicy { + const retryPolicy = retry(retryFilterPolicy, { // Note that although the option here is called "max attempts", it's really // maximum number of *retries* (attempts past the initial attempt). - maxAttempts: DEFAULT_MAX_RETRIES, + maxAttempts: maxRetries, // Retries of the service will be executed following ever increasing delays, // determined by a backoff formula. backoff: new ExponentialBackoff(), }); + const onRetry = retryPolicy.onRetry.bind(retryPolicy); const circuitBreakerPolicy = circuitBreaker(handleAll, { // While the circuit is open, any additional invocations of the service @@ -136,27 +205,12 @@ export function createServicePolicy({ halfOpenAfter: circuitBreakDuration, breaker: new ConsecutiveBreaker(maxConsecutiveFailures), }); + const onBreak = circuitBreakerPolicy.onBreak.bind(circuitBreakerPolicy); - // The `onBreak` callback will be called if the service consistently throws - // for as many times as exceeds the maximum consecutive number of failures. - // Combined with the retry policy, this can happen if: - // - `maxConsecutiveFailures` < the default max retries (3) and the policy is - // executed once - // - `maxConsecutiveFailures` >= the default max retries (3) but the policy is - // executed multiple times, enough for the total number of retries to exceed - // `maxConsecutiveFailures` - circuitBreakerPolicy.onBreak(onBreak); - - // The `onRetryPolicy` callback will be called each time the service is - // invoked (including retries). - retryPolicy.onRetry(onRetry); - + const onDegradedEventEmitter = new CockatielEventEmitter(); retryPolicy.onGiveUp(() => { if (circuitBreakerPolicy.state === CircuitState.Closed) { - // The `onDegraded` callback will be called if the number of retries is - // exceeded and the maximum number of consecutive failures has not been - // reached yet (whether the policy is called once or multiple times). - onDegraded(); + onDegradedEventEmitter.emit(); } }); retryPolicy.onSuccess(({ duration }) => { @@ -164,14 +218,21 @@ export function createServicePolicy({ circuitBreakerPolicy.state === CircuitState.Closed && duration > degradedThreshold ) { - // The `onDegraded` callback will also be called if the service does not - // throw, but the time it takes for the service to run exceeds the - // `degradedThreshold`. - onDegraded(); + onDegradedEventEmitter.emit(); } }); + const onDegraded = onDegradedEventEmitter.addListener; + + // Every time the retry policy makes an attempt, it executes the circuit + // breaker policy, which executes the service. + const policy = wrap(retryPolicy, circuitBreakerPolicy); - // The retry policy really retries the circuit breaker policy, which invokes - // the service. - return wrap(retryPolicy, circuitBreakerPolicy); + return { + ...policy, + circuitBreakerPolicy, + retryPolicy, + onBreak, + onDegraded, + onRetry, + }; } diff --git a/packages/controller-utils/src/index.test.ts b/packages/controller-utils/src/index.test.ts index 61ef841826f..f6db054b3ce 100644 --- a/packages/controller-utils/src/index.test.ts +++ b/packages/controller-utils/src/index.test.ts @@ -4,7 +4,15 @@ describe('@metamask/controller-utils', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` Array [ + "BrokenCircuitError", + "CircuitState", + "DEFAULT_CIRCUIT_BREAK_DURATION", + "DEFAULT_DEGRADED_THRESHOLD", + "DEFAULT_MAX_CONSECUTIVE_FAILURES", + "DEFAULT_MAX_RETRIES", "createServicePolicy", + "handleAll", + "handleWhen", "BNToHex", "convertHexToDecimal", "fetchWithErrorHandling", diff --git a/packages/controller-utils/src/index.ts b/packages/controller-utils/src/index.ts index b3bd8821e12..155c269217c 100644 --- a/packages/controller-utils/src/index.ts +++ b/packages/controller-utils/src/index.ts @@ -1,4 +1,19 @@ -export { createServicePolicy } from './create-service-policy'; +export { + BrokenCircuitError, + CircuitState, + DEFAULT_CIRCUIT_BREAK_DURATION, + DEFAULT_DEGRADED_THRESHOLD, + DEFAULT_MAX_CONSECUTIVE_FAILURES, + DEFAULT_MAX_RETRIES, + createServicePolicy, + handleAll, + handleWhen, +} from './create-service-policy'; +export type { + CockatielEvent, + CreateServicePolicyOptions, + ServicePolicy, +} from './create-service-policy'; export * from './constants'; export type { NonEmptyArray } from './util'; export { diff --git a/packages/earn-controller/CHANGELOG.md b/packages/earn-controller/CHANGELOG.md new file mode 100644 index 00000000000..c7952557ca0 --- /dev/null +++ b/packages/earn-controller/CHANGELOG.md @@ -0,0 +1,39 @@ +# 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] + +## [0.3.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^23.0.0` to `^24.0.0` ([#5318](https://github.com/MetaMask/core/pull/5318)) + +## [0.2.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) + +## [0.2.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^22.0.0` to `^23.0.0` ([#5292](https://github.com/MetaMask/core/pull/5292)) +- Bump `@metamask/controller-utils` dependency from `^11.4.5` to `^11.5.0`([#5272](https://github.com/MetaMask/core/pull/5272)) + +## [0.1.0] + +### Added + +- Initial release ([#5271](https://github.com/MetaMask/core/pull/5271)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.3.0...HEAD +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.2.1...@metamask/earn-controller@0.3.0 +[0.2.1]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.2.0...@metamask/earn-controller@0.2.1 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.1.0...@metamask/earn-controller@0.2.0 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/earn-controller@0.1.0 diff --git a/packages/earn-controller/LICENSE b/packages/earn-controller/LICENSE new file mode 100644 index 00000000000..7d002dced3a --- /dev/null +++ b/packages/earn-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/earn-controller/README.md b/packages/earn-controller/README.md new file mode 100644 index 00000000000..67da818b7f2 --- /dev/null +++ b/packages/earn-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/earn-controller` + +Manages state for earning features and coordinates interactions between staking services, SDK integrations, and other controllers to enable users to participate in various earning opportunities. + +## Installation + +`yarn add @metamask/earn-controller` + +or + +`npm install @metamask/earn-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/earn-controller/jest.config.js b/packages/earn-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/earn-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/earn-controller/package.json b/packages/earn-controller/package.json new file mode 100644 index 00000000000..46ffb282b29 --- /dev/null +++ b/packages/earn-controller/package.json @@ -0,0 +1,78 @@ +{ + "name": "@metamask/earn-controller", + "version": "0.3.0", + "description": "Manages state for earning features and coordinates interactions between staking services, SDK integrations, and other controllers to enable users to participate in various earning opportunities", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/earn-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:docs": "typedoc", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/earn-controller", + "changelog:update": "../../scripts/update-changelog.sh @metamask/earn-controller", + "publish:preview": "yarn npm publish --tag preview", + "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", + "since-latest-release": "../../scripts/since-latest-release.sh" + }, + "dependencies": { + "@ethersproject/providers": "^5.7.0", + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", + "@metamask/stake-sdk": "^1.0.0" + }, + "devDependencies": { + "@metamask/accounts-controller": "^24.0.0", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/network-controller": "^22.2.1", + "@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.2.2" + }, + "peerDependencies": { + "@metamask/accounts-controller": "^24.0.0", + "@metamask/network-controller": "^22.1.1" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/earn-controller/src/EarnController.test.ts b/packages/earn-controller/src/EarnController.test.ts new file mode 100644 index 00000000000..3b0ab5ca796 --- /dev/null +++ b/packages/earn-controller/src/EarnController.test.ts @@ -0,0 +1,371 @@ +import type { AccountsController } from '@metamask/accounts-controller'; +import { Messenger } from '@metamask/base-controller'; +import { getDefaultNetworkControllerState } from '@metamask/network-controller'; +import { StakeSdk, StakingApiService } from '@metamask/stake-sdk'; + +import { + EarnController, + type EarnControllerState, + getDefaultEarnControllerState, + type EarnControllerMessenger, + type EarnControllerEvents, + type EarnControllerActions, + type AllowedActions, + type AllowedEvents, +} from './EarnController'; + +jest.mock('@metamask/stake-sdk', () => ({ + StakeSdk: { + create: jest.fn().mockImplementation(() => ({ + pooledStakingContract: { + connectSignerOrProvider: jest.fn(), // Mock connectSignerOrProvider + }, + })), + }, + StakingApiService: jest.fn().mockImplementation(() => ({ + getPooledStakes: jest.fn(), + getPooledStakingEligibility: jest.fn(), + getVaultData: jest.fn(), + })), +})); + +/** + * Builds a new instance of the Messenger class for the AccountsController. + * + * @returns A new instance of the Messenger class for the AccountsController. + */ +function buildMessenger() { + return new Messenger< + EarnControllerActions | AllowedActions, + EarnControllerEvents | AllowedEvents + >(); +} + +/** + * Constructs the messenger which is restricted to relevant EarnController + * actions and events. + * + * @param rootMessenger - The root messenger to restrict. + * @returns The restricted messenger. + */ +function getEarnControllerMessenger( + rootMessenger = buildMessenger(), +): EarnControllerMessenger { + return rootMessenger.getRestricted({ + name: 'EarnController', + allowedActions: [ + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'AccountsController:getSelectedAccount', + ], + allowedEvents: [ + 'NetworkController:stateChange', + 'AccountsController:selectedAccountChange', + ], + }); +} + +type InternalAccount = ReturnType; + +const createMockInternalAccount = ({ + id = '123e4567-e89b-12d3-a456-426614174000', + address = '0x2990079bcdee240329a520d2444386fc119da21a', + name = 'Account 1', + importTime = Date.now(), + lastSelected = Date.now(), +}: { + id?: string; + address?: string; + name?: string; + importTime?: number; + lastSelected?: number; +} = {}): InternalAccount => { + return { + id, + address, + options: {}, + methods: [], + type: 'eip155:eoa', + scopes: ['eip155:1'], + metadata: { + name, + keyring: { type: 'HD Key Tree' }, + importTime, + lastSelected, + }, + }; +}; + +const mockPooledStakes = { + account: '0x1234', + lifetimeRewards: '100', + assets: '1000', + exitRequests: [], +}; +const mockVaultData = { + apy: '5.5', + capacity: '1000000', + feePercent: 10, + totalAssets: '500000', + vaultAddress: '0xabcd', +}; + +const setupController = ({ + options = {}, + + mockGetNetworkClientById = jest.fn(() => ({ + configuration: { chainId: '0x1' }, + provider: { + request: jest.fn(), + on: jest.fn(), + removeListener: jest.fn(), + }, + })), + + mockGetNetworkControllerState = jest.fn(() => ({ + selectedNetworkClientId: '1', + networkConfigurations: { + '1': { chainId: '0x1' }, + }, + })), + + mockGetSelectedAccount = jest.fn(() => ({ + address: '0x1234', + })), +}: { + options?: Partial[0]>; + mockGetNetworkClientById?: jest.Mock; + mockGetNetworkControllerState?: jest.Mock; + mockGetSelectedAccount?: jest.Mock; +} = {}) => { + const messenger = buildMessenger(); + + messenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + mockGetNetworkClientById, + ); + messenger.registerActionHandler( + 'NetworkController:getState', + mockGetNetworkControllerState, + ); + messenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + mockGetSelectedAccount, + ); + + const earnControllerMessenger = getEarnControllerMessenger(messenger); + + const controller = new EarnController({ + messenger: earnControllerMessenger, + ...options, + }); + + return { controller, messenger }; +}; + +const StakingApiServiceMock = jest.mocked(StakingApiService); +let mockedStakingApiService: Partial; + +describe('EarnController', () => { + beforeEach(() => { + // Apply StakeSdk mock before initializing EarnController + (StakeSdk.create as jest.Mock).mockImplementation(() => ({ + pooledStakingContract: { + connectSignerOrProvider: jest.fn(), + }, + })); + + mockedStakingApiService = { + getPooledStakes: jest.fn().mockResolvedValue({ + accounts: [mockPooledStakes], + exchangeRate: '1.5', + }), + getPooledStakingEligibility: jest.fn().mockResolvedValue({ + eligible: true, + }), + getVaultData: jest.fn().mockResolvedValue(mockVaultData), + } as Partial; + + StakingApiServiceMock.mockImplementation( + () => mockedStakingApiService as StakingApiService, + ); + }); + + describe('constructor', () => { + it('initializes with default state when no state is provided', () => { + const { controller } = setupController(); + expect(controller.state).toStrictEqual(getDefaultEarnControllerState()); + }); + + it('uses provided state to initialize', () => { + const customState: Partial = { + pooled_staking: { + pooledStakes: mockPooledStakes, + exchangeRate: '1.5', + vaultData: mockVaultData, + isEligible: true, + }, + lastUpdated: 1234567890, + }; + + const { controller } = setupController({ + options: { state: customState }, + }); + + expect(controller.state).toStrictEqual({ + ...getDefaultEarnControllerState(), + ...customState, + }); + }); + }); + + describe('SDK initialization', () => { + it('initializes SDK with correct chain ID on construction', () => { + setupController(); + expect(StakeSdk.create).toHaveBeenCalledWith({ + chainId: 1, + }); + }); + + it('handles SDK initialization failure gracefully by avoiding known errors', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + (StakeSdk.create as jest.Mock).mockImplementationOnce(() => { + throw new Error('Unsupported chainId'); + }); + + // Unsupported chain id should not result in console error statement + setupController(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + it('handles SDK initialization failure gracefully by logging error', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + (StakeSdk.create as jest.Mock).mockImplementationOnce(() => { + throw new Error('Network error'); + }); + + // Unexpected error should be logged + setupController(); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + it('reinitializes SDK when network changes', () => { + const { messenger } = setupController(); + + messenger.publish( + 'NetworkController:stateChange', + { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '2', + }, + [], + ); + + expect(StakeSdk.create).toHaveBeenCalledTimes(2); + expect(mockedStakingApiService.getPooledStakes).toHaveBeenCalled(); + }); + + it('does not initialize sdk if the provider is null', () => { + setupController({ + mockGetNetworkClientById: jest.fn(() => ({ + provider: null, + configuration: { chainId: '0x1' }, + })), + }); + expect(StakeSdk.create).not.toHaveBeenCalled(); + }); + }); + + describe('refreshPooledStakingData', () => { + it('updates state with fetched staking data', async () => { + const { controller } = setupController(); + await controller.refreshPooledStakingData(); + + expect(controller.state.pooled_staking).toStrictEqual({ + pooledStakes: mockPooledStakes, + exchangeRate: '1.5', + vaultData: mockVaultData, + isEligible: true, + }); + expect(controller.state.lastUpdated).toBeDefined(); + }); + + it('handles API errors gracefully', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mockedStakingApiService = { + getPooledStakes: jest.fn().mockImplementation(() => { + throw new Error('API Error'); + }), + getPooledStakingEligibility: jest.fn().mockImplementation(() => { + throw new Error('API Error'); + }), + getVaultData: jest.fn().mockImplementation(() => { + throw new Error('API Error'); + }), + }; + + StakingApiServiceMock.mockImplementation( + () => mockedStakingApiService as StakingApiService, + ); + + const { controller } = setupController(); + + await expect(controller.refreshPooledStakingData()).rejects.toThrow( + 'Failed to refresh some staking data: API Error, API Error, API Error', + ); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + // if no account is selected, it should not fetch stakes data but still updates vault data + it('does not fetch staking data if no account is selected', async () => { + const { controller } = setupController({ + mockGetSelectedAccount: jest.fn(() => null), + }); + + expect(mockedStakingApiService.getPooledStakes).not.toHaveBeenCalled(); + await controller.refreshPooledStakingData(); + + expect(controller.state.pooled_staking.pooledStakes).toStrictEqual( + getDefaultEarnControllerState().pooled_staking.pooledStakes, + ); + expect(controller.state.pooled_staking.vaultData).toStrictEqual( + mockVaultData, + ); + expect(controller.state.pooled_staking.isEligible).toBe(false); + }); + }); + + describe('subscription handlers', () => { + const firstAccount = createMockInternalAccount({ + address: '0x1234', + }); + + it('updates staking data when network changes', () => { + const { controller, messenger } = setupController(); + jest.spyOn(controller, 'refreshPooledStakingData').mockResolvedValue(); + messenger.publish( + 'NetworkController:stateChange', + { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '2', + }, + [], + ); + + expect(controller.refreshPooledStakingData).toHaveBeenCalled(); + }); + + it('updates staking data when selected account changes', () => { + const { controller, messenger } = setupController(); + jest.spyOn(controller, 'refreshPooledStakingData').mockResolvedValue(); + messenger.publish( + 'AccountsController:selectedAccountChange', + firstAccount, + ); + expect(controller.refreshPooledStakingData).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/earn-controller/src/EarnController.ts b/packages/earn-controller/src/EarnController.ts new file mode 100644 index 00000000000..5ce6e71280e --- /dev/null +++ b/packages/earn-controller/src/EarnController.ts @@ -0,0 +1,392 @@ +import { Web3Provider } from '@ethersproject/providers'; +import type { + AccountsControllerGetSelectedAccountAction, + AccountsControllerSelectedAccountChangeEvent, +} from '@metamask/accounts-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + RestrictedMessenger, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import { convertHexToDecimal } from '@metamask/controller-utils'; +import type { NetworkControllerStateChangeEvent } from '@metamask/network-controller'; +import type { + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, +} from '@metamask/network-controller'; +import { + StakeSdk, + StakingApiService, + type PooledStake, + type StakeSdkConfig, + type VaultData, +} from '@metamask/stake-sdk'; + +export const controllerName = 'EarnController'; + +export type PooledStakingState = { + pooledStakes: PooledStake; + exchangeRate: string; + vaultData: VaultData; + isEligible: boolean; +}; + +export type StablecoinLendingState = { + vaults: StablecoinVault[]; +}; + +export type StablecoinVault = { + symbol: string; + name: string; + chainId: number; + tokenAddress: string; + vaultAddress: string; + currentAPY: string; + supply: string; + liquidity: string; +}; + +/** + * Metadata for the EarnController. + */ +const earnControllerMetadata: StateMetadata = { + pooled_staking: { + persist: true, + anonymous: false, + }, + stablecoin_lending: { + persist: true, + anonymous: false, + }, + lastUpdated: { + persist: false, + anonymous: true, + }, +}; + +// === State Types === +export type EarnControllerState = { + pooled_staking: PooledStakingState; + stablecoin_lending?: StablecoinLendingState; + lastUpdated: number; +}; + +// === Default State === +const DEFAULT_STABLECOIN_VAULT: StablecoinVault = { + symbol: '', + name: '', + chainId: 0, + tokenAddress: '', + vaultAddress: '', + currentAPY: '0', + supply: '0', + liquidity: '0', +}; + +/** + * Gets the default state for the EarnController. + * + * @returns The default EarnController state. + */ +export function getDefaultEarnControllerState(): EarnControllerState { + return { + pooled_staking: { + pooledStakes: { + account: '', + lifetimeRewards: '0', + assets: '0', + exitRequests: [], + }, + exchangeRate: '1', + vaultData: { + apy: '0', + capacity: '0', + feePercent: 0, + totalAssets: '0', + vaultAddress: '0x0000000000000000000000000000000000000000', + }, + isEligible: false, + }, + stablecoin_lending: { + vaults: [DEFAULT_STABLECOIN_VAULT], + }, + lastUpdated: 0, + }; +} + +// === MESSENGER === + +/** + * The action which can be used to retrieve the state of the EarnController. + */ +export type EarnControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + EarnControllerState +>; + +/** + * All actions that EarnController registers, to be called externally. + */ +export type EarnControllerActions = EarnControllerGetStateAction; + +/** + * All actions that EarnController calls internally. + */ +export type AllowedActions = + | NetworkControllerGetNetworkClientByIdAction + | NetworkControllerGetStateAction + | AccountsControllerGetSelectedAccountAction; + +/** + * The event that EarnController publishes when updating state. + */ +export type EarnControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + EarnControllerState +>; + +/** + * All events that EarnController publishes, to be subscribed to externally. + */ +export type EarnControllerEvents = EarnControllerStateChangeEvent; + +/** + * All events that EarnController subscribes to internally. + */ +export type AllowedEvents = + | AccountsControllerSelectedAccountChangeEvent + | NetworkControllerStateChangeEvent; + +/** + * The messenger which is restricted to actions and events accessed by + * EarnController. + */ +export type EarnControllerMessenger = RestrictedMessenger< + typeof controllerName, + EarnControllerActions | AllowedActions, + EarnControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + +// === CONTROLLER DEFINITION === + +/** + * EarnController manages DeFi earning opportunities across different protocols and chains. + */ +export class EarnController extends BaseController< + typeof controllerName, + EarnControllerState, + EarnControllerMessenger +> { + #stakeSDK: StakeSdk | null = null; + + #selectedNetworkClientId?: string; + + readonly #stakingApiService: StakingApiService = new StakingApiService(); + + constructor({ + messenger, + state = {}, + }: { + messenger: EarnControllerMessenger; + state?: Partial; + }) { + super({ + name: controllerName, + metadata: earnControllerMetadata, + messenger, + state: { + ...getDefaultEarnControllerState(), + ...state, + }, + }); + + this.#initializeSDK(); + this.refreshPooledStakingData().catch(console.error); + + const { selectedNetworkClientId } = this.messagingSystem.call( + 'NetworkController:getState', + ); + this.#selectedNetworkClientId = selectedNetworkClientId; + + this.messagingSystem.subscribe( + 'NetworkController:stateChange', + (networkControllerState) => { + if ( + networkControllerState.selectedNetworkClientId !== + this.#selectedNetworkClientId + ) { + this.#initializeSDK(networkControllerState.selectedNetworkClientId); + this.refreshPooledStakingData().catch(console.error); + } + this.#selectedNetworkClientId = + networkControllerState.selectedNetworkClientId; + }, + ); + + // Listen for account changes + this.messagingSystem.subscribe( + 'AccountsController:selectedAccountChange', + () => { + this.refreshPooledStakingData().catch(console.error); + }, + ); + } + + #initializeSDK(networkClientId?: string) { + const { selectedNetworkClientId } = networkClientId + ? { selectedNetworkClientId: networkClientId } + : this.messagingSystem.call('NetworkController:getState'); + + const networkClient = this.messagingSystem.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, + ); + + if (!networkClient?.provider) { + this.#stakeSDK = null; + return; + } + + const provider = new Web3Provider(networkClient.provider); + const { chainId } = networkClient.configuration; + + // Initialize appropriate contracts based on chainId + const config: StakeSdkConfig = { + chainId: convertHexToDecimal(chainId), + }; + + try { + this.#stakeSDK = StakeSdk.create(config); + this.#stakeSDK.pooledStakingContract.connectSignerOrProvider(provider); + } catch (error) { + this.#stakeSDK = null; + // Only log unexpected errors, not unsupported chain errors + if ( + !( + error instanceof Error && + error.message.includes('Unsupported chainId') + ) + ) { + console.error('Stake SDK initialization failed:', error); + } + } + } + + #getCurrentAccount() { + return this.messagingSystem.call('AccountsController:getSelectedAccount'); + } + + #getCurrentChainId(): number { + const { selectedNetworkClientId } = this.messagingSystem.call( + 'NetworkController:getState', + ); + const { + configuration: { chainId }, + } = this.messagingSystem.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, + ); + return convertHexToDecimal(chainId); + } + + /** + * Refreshes the pooled stakes data for the current account. + * Fetches updated stake information including lifetime rewards, assets, and exit requests + * from the staking API service and updates the state. + * + * @returns A promise that resolves when the stakes data has been updated + */ + async refreshPooledStakes(): Promise { + const currentAccount = this.#getCurrentAccount(); + if (!currentAccount?.address) { + return; + } + + const chainId = this.#getCurrentChainId(); + + const { accounts, exchangeRate } = + await this.#stakingApiService.getPooledStakes( + [currentAccount.address], + chainId, + ); + + this.update((state) => { + state.pooled_staking.pooledStakes = accounts[0]; + state.pooled_staking.exchangeRate = exchangeRate; + }); + } + + /** + * Refreshes the staking eligibility status for the current account. + * Updates the eligibility status in the controller state based on the location and address blocklist for compliance. + * + * @returns A promise that resolves when the eligibility status has been updated + */ + async refreshStakingEligibility(): Promise { + const currentAccount = this.#getCurrentAccount(); + if (!currentAccount?.address) { + return; + } + + const { eligible: isEligible } = + await this.#stakingApiService.getPooledStakingEligibility([ + currentAccount.address, + ]); + + this.update((state) => { + state.pooled_staking.isEligible = isEligible; + }); + } + + /** + * Refreshes vault data for the current chain. + * Updates the vault data in the controller state including APY, capacity, + * fee percentage, total assets, and vault address. + * + * @returns A promise that resolves when the vault data has been updated + */ + async refreshVaultData(): Promise { + const chainId = this.#getCurrentChainId(); + const vaultData = await this.#stakingApiService.getVaultData(chainId); + + this.update((state) => { + state.pooled_staking.vaultData = vaultData; + }); + } + + /** + * Refreshes all pooled staking related data including stakes, eligibility, and vault data. + * This method allows partial success, meaning some data may update while other requests fail. + * All errors are collected and thrown as a single error message. + * + * @returns A promise that resolves when all possible data has been updated + * @throws {Error} If any of the refresh operations fail, with concatenated error messages + */ + async refreshPooledStakingData(): Promise { + const errors: Error[] = []; + + await Promise.all([ + this.refreshPooledStakes().catch((error) => { + errors.push(error); + }), + this.refreshStakingEligibility().catch((error) => { + errors.push(error); + }), + this.refreshVaultData().catch((error) => { + errors.push(error); + }), + ]); + + if (errors.length > 0) { + throw new Error( + `Failed to refresh some staking data: ${errors + .map((e) => e.message) + .join(', ')}`, + ); + } + } +} diff --git a/packages/earn-controller/src/index.ts b/packages/earn-controller/src/index.ts new file mode 100644 index 00000000000..98ed7a4567d --- /dev/null +++ b/packages/earn-controller/src/index.ts @@ -0,0 +1,17 @@ +export type { + PooledStakingState, + StablecoinLendingState, + StablecoinVault, + EarnControllerState, + EarnControllerGetStateAction, + EarnControllerStateChangeEvent, + EarnControllerActions, + EarnControllerEvents, + EarnControllerMessenger, +} from './EarnController'; + +export { + controllerName, + getDefaultEarnControllerState, + EarnController, +} from './EarnController'; diff --git a/packages/earn-controller/tsconfig.build.json b/packages/earn-controller/tsconfig.build.json new file mode 100644 index 00000000000..60df451b564 --- /dev/null +++ b/packages/earn-controller/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../network-controller/tsconfig.build.json" + }, + { + "path": "../accounts-controller/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/earn-controller/tsconfig.json b/packages/earn-controller/tsconfig.json new file mode 100644 index 00000000000..bf1ccd4b0e7 --- /dev/null +++ b/packages/earn-controller/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "include": ["../../types", "./src"], + "references": [ + { + "path": "../base-controller" + }, + { + "path": "../network-controller" + }, + { + "path": "../accounts-controller" + } + ] +} diff --git a/packages/earn-controller/typedoc.json b/packages/earn-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/earn-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/ens-controller/CHANGELOG.md b/packages/ens-controller/CHANGELOG.md index 7b0704f80b5..c1be9d44f6c 100644 --- a/packages/ens-controller/CHANGELOG.md +++ b/packages/ens-controller/CHANGELOG.md @@ -7,9 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [15.0.2] + ### Changed -- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) +- Bump `@metamask/base-controller` from `^7.0.2` to `^8.0.0` ([#5079](https://github.com/MetaMask/core/pull/5079)), ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^10.0.0` to `^11.1.0` ([#5080](https://github.com/MetaMask/core/pull/5080)), ([#5223](https://github.com/MetaMask/core/pull/5223)) ## [15.0.1] @@ -271,7 +275,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/ens-controller@15.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@15.0.2...HEAD +[15.0.2]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@15.0.1...@metamask/ens-controller@15.0.2 [15.0.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@15.0.0...@metamask/ens-controller@15.0.1 [15.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@14.0.1...@metamask/ens-controller@15.0.0 [14.0.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@14.0.0...@metamask/ens-controller@14.0.1 diff --git a/packages/ens-controller/package.json b/packages/ens-controller/package.json index 03fdaaf1069..0d73de03b5b 100644 --- a/packages/ens-controller/package.json +++ b/packages/ens-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/ens-controller", - "version": "15.0.1", + "version": "15.0.2", "description": "Maps ENS names to their resolved addresses by chain id", "keywords": [ "MetaMask", @@ -48,14 +48,14 @@ }, "dependencies": { "@ethersproject/providers": "^5.7.0", - "@metamask/base-controller": "^7.1.1", - "@metamask/controller-utils": "^11.4.5", - "@metamask/utils": "^11.0.1", + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", + "@metamask/utils": "^11.1.0", "punycode": "^2.1.1" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^22.1.1", + "@metamask/network-controller": "^22.2.1", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/ens-controller/src/EnsController.test.ts b/packages/ens-controller/src/EnsController.test.ts index 00f384ddfe5..9643074a27d 100644 --- a/packages/ens-controller/src/EnsController.test.ts +++ b/packages/ens-controller/src/EnsController.test.ts @@ -1,5 +1,5 @@ import * as providersModule from '@ethersproject/providers'; -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { toChecksumHexAddress, toHex, @@ -54,7 +54,7 @@ jest.mock('@ethersproject/providers', () => { }; }); -type RootMessenger = ControllerMessenger< +type RootMessenger = Messenger< ExtractAvailableAction, ExtractAvailableEvent >; @@ -76,10 +76,10 @@ const name = 'EnsController'; /** * Constructs the root messenger. * - * @returns A restricted controller messenger. + * @returns A restricted messenger. */ function getRootMessenger(): RootMessenger { - return new ControllerMessenger< + return new Messenger< ExtractAvailableAction | AllowedActions, ExtractAvailableEvent | never >(); @@ -91,7 +91,7 @@ function getRootMessenger(): RootMessenger { * @param rootMessenger - The root messenger to base the restricted messenger * off of. * @param getNetworkClientByIdMock - Optional mock version of `getNetworkClientById`. - * @returns A restricted controller messenger. + * @returns A restricted messenger. */ function getRestrictedMessenger( rootMessenger: RootMessenger, diff --git a/packages/ens-controller/src/EnsController.ts b/packages/ens-controller/src/EnsController.ts index dea2edfe5fb..1dba71cb6ae 100644 --- a/packages/ens-controller/src/EnsController.ts +++ b/packages/ens-controller/src/EnsController.ts @@ -1,5 +1,5 @@ import { Web3Provider } from '@ethersproject/providers'; -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; +import type { RestrictedMessenger } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { ChainId } from '@metamask/controller-utils'; import { @@ -73,7 +73,7 @@ export type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | NetworkControllerGetStateAction; -export type EnsControllerMessenger = RestrictedControllerMessenger< +export type EnsControllerMessenger = RestrictedMessenger< typeof name, AllowedActions, never, diff --git a/packages/eth-json-rpc-provider/CHANGELOG.md b/packages/eth-json-rpc-provider/CHANGELOG.md index 9e7b09d30fe..8fbfd25f1a2 100644 --- a/packages/eth-json-rpc-provider/CHANGELOG.md +++ b/packages/eth-json-rpc-provider/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.1.8] + +### Changed + +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + ## [4.1.7] ### Changed @@ -183,7 +189,8 @@ Release `v2.0.0` is identical to `v1.0.1` aside from Node.js version requirement - Initial release, including `providerFromEngine` and `providerFromMiddleware`. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@4.1.7...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@4.1.8...HEAD +[4.1.8]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@4.1.7...@metamask/eth-json-rpc-provider@4.1.8 [4.1.7]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@4.1.6...@metamask/eth-json-rpc-provider@4.1.7 [4.1.6]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@4.1.5...@metamask/eth-json-rpc-provider@4.1.6 [4.1.5]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@4.1.4...@metamask/eth-json-rpc-provider@4.1.5 diff --git a/packages/eth-json-rpc-provider/package.json b/packages/eth-json-rpc-provider/package.json index 024b4a6e51f..a0849f70806 100644 --- a/packages/eth-json-rpc-provider/package.json +++ b/packages/eth-json-rpc-provider/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/eth-json-rpc-provider", - "version": "4.1.7", + "version": "4.1.8", "description": "Create an Ethereum provider using a JSON-RPC engine or middleware", "keywords": [ "MetaMask", @@ -52,10 +52,10 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/json-rpc-engine": "^10.0.2", + "@metamask/json-rpc-engine": "^10.0.3", "@metamask/rpc-errors": "^7.0.2", "@metamask/safe-event-emitter": "^3.0.0", - "@metamask/utils": "^11.0.1", + "@metamask/utils": "^11.1.0", "uuid": "^8.3.2" }, "devDependencies": { diff --git a/packages/gas-fee-controller/CHANGELOG.md b/packages/gas-fee-controller/CHANGELOG.md index eb7f7149bb9..dabf4508878 100644 --- a/packages/gas-fee-controller/CHANGELOG.md +++ b/packages/gas-fee-controller/CHANGELOG.md @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [22.0.3] + ### Changed -- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) +- Bump `@metamask/base-controller` from `^7.0.2` to `^8.0.0` ([#5079](https://github.com/MetaMask/core/pull/5079)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/polling-controller` from `^12.0.2` to `^12.0.3` ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/utils` from `^10.0.0` to `^11.1.0` ([#5080](https://github.com/MetaMask/core/pull/5080)), ([#5223](https://github.com/MetaMask/core/pull/5223)) ## [22.0.2] @@ -397,7 +402,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/gas-fee-controller@22.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@22.0.3...HEAD +[22.0.3]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@22.0.2...@metamask/gas-fee-controller@22.0.3 [22.0.2]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@22.0.1...@metamask/gas-fee-controller@22.0.2 [22.0.1]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@22.0.0...@metamask/gas-fee-controller@22.0.1 [22.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@21.0.0...@metamask/gas-fee-controller@22.0.0 diff --git a/packages/gas-fee-controller/package.json b/packages/gas-fee-controller/package.json index 5e2853f34c1..af6f8a76847 100644 --- a/packages/gas-fee-controller/package.json +++ b/packages/gas-fee-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/gas-fee-controller", - "version": "22.0.2", + "version": "22.0.3", "description": "Periodically calculates gas fee estimates based on various gas limits as well as other data displayed on transaction confirm screens", "keywords": [ "MetaMask", @@ -47,12 +47,12 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/controller-utils": "^11.4.5", + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", "@metamask/eth-query": "^4.0.0", "@metamask/ethjs-unit": "^0.3.0", - "@metamask/polling-controller": "^12.0.2", - "@metamask/utils": "^11.0.1", + "@metamask/polling-controller": "^12.0.3", + "@metamask/utils": "^11.1.0", "@types/bn.js": "^5.1.5", "@types/uuid": "^8.3.0", "bn.js": "^5.2.1", @@ -61,7 +61,7 @@ "devDependencies": { "@babel/runtime": "^7.23.9", "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^22.1.1", + "@metamask/network-controller": "^22.2.1", "@types/jest": "^27.4.1", "@types/jest-when": "^2.7.3", "deepmerge": "^4.2.2", diff --git a/packages/gas-fee-controller/src/GasFeeController.test.ts b/packages/gas-fee-controller/src/GasFeeController.test.ts index 4b4bf928b3f..d2ad90b6241 100644 --- a/packages/gas-fee-controller/src/GasFeeController.test.ts +++ b/packages/gas-fee-controller/src/GasFeeController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { ChainId, convertHexToDecimal, @@ -47,7 +47,7 @@ const mockedDetermineGasFeeCalculations = const name = 'GasFeeController'; -type MainControllerMessenger = ControllerMessenger< +type MainMessenger = Messenger< | GetGasFeeState | NetworkControllerGetStateAction | NetworkControllerGetNetworkClientByIdAction @@ -55,8 +55,8 @@ type MainControllerMessenger = ControllerMessenger< GasFeeStateChange | NetworkControllerNetworkDidChangeEvent >; -const getControllerMessenger = (): MainControllerMessenger => { - return new ControllerMessenger(); +const getMessenger = (): MainMessenger => { + return new Messenger(); }; const setupNetworkController = async ({ @@ -65,7 +65,7 @@ const setupNetworkController = async ({ clock, initializeProvider = true, }: { - unrestrictedMessenger: MainControllerMessenger; + unrestrictedMessenger: MainMessenger; state: Partial; clock: sinon.SinonFakeTimers; initializeProvider?: boolean; @@ -80,6 +80,8 @@ const setupNetworkController = async ({ messenger: restrictedMessenger, state, infuraProjectId: '123', + fetch, + btoa, }); if (initializeProvider) { @@ -96,10 +98,8 @@ const setupNetworkController = async ({ return networkController; }; -const getRestrictedMessenger = ( - controllerMessenger: MainControllerMessenger, -) => { - const messenger = controllerMessenger.getRestricted({ +const getRestrictedMessenger = (messenger: MainMessenger) => { + return messenger.getRestricted({ name, allowedActions: [ 'NetworkController:getState', @@ -108,8 +108,6 @@ const getRestrictedMessenger = ( ], allowedEvents: ['NetworkController:networkDidChange'], }); - - return messenger; }; /** @@ -265,19 +263,19 @@ describe('GasFeeController', () => { interval?: number; initializeNetworkProvider?: boolean; } = {}) { - const controllerMessenger = getControllerMessenger(); + const messenger = getMessenger(); networkController = await setupNetworkController({ - unrestrictedMessenger: controllerMessenger, + unrestrictedMessenger: messenger, state: networkControllerState, clock, initializeProvider: initializeNetworkProvider, }); - const messenger = getRestrictedMessenger(controllerMessenger); + const restrictedMessenger = getRestrictedMessenger(messenger); gasFeeController = new GasFeeController({ getProvider: jest.fn(), getChainId, onNetworkDidChange, - messenger, + messenger: restrictedMessenger, getCurrentNetworkLegacyGasAPICompatibility, getCurrentNetworkEIP1559Compatibility: getIsEIP1559Compatible, // change this for networkDetails.state.networkDetails.isEIP1559Compatible ??? legacyAPIEndpoint, diff --git a/packages/gas-fee-controller/src/GasFeeController.ts b/packages/gas-fee-controller/src/GasFeeController.ts index 13587418a33..c26a08ee28b 100644 --- a/packages/gas-fee-controller/src/GasFeeController.ts +++ b/packages/gas-fee-controller/src/GasFeeController.ts @@ -1,7 +1,7 @@ import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { convertHexToDecimal, @@ -240,7 +240,7 @@ type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | NetworkControllerGetEIP1559CompatibilityAction; -type GasFeeMessenger = RestrictedControllerMessenger< +type GasFeeMessenger = RestrictedMessenger< typeof name, GasFeeControllerActions | AllowedActions, GasFeeControllerEvents | NetworkControllerNetworkDidChangeEvent, diff --git a/packages/json-rpc-engine/CHANGELOG.md b/packages/json-rpc-engine/CHANGELOG.md index c4a2004981b..3db58459f2e 100644 --- a/packages/json-rpc-engine/CHANGELOG.md +++ b/packages/json-rpc-engine/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [10.0.3] + +### Changed + +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + ## [10.0.2] ### Changed @@ -223,7 +229,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 This change may affect consumers that depend on the eager execution of middleware _during_ request processing, _outside of_ middleware functions and request handlers. - In general, it is a bad practice to work with state that depends on middleware execution, while the middleware are executing. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.0.3...HEAD +[10.0.3]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.0.2...@metamask/json-rpc-engine@10.0.3 [10.0.2]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.0.1...@metamask/json-rpc-engine@10.0.2 [10.0.1]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.0.0...@metamask/json-rpc-engine@10.0.1 [10.0.0]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@9.0.3...@metamask/json-rpc-engine@10.0.0 diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index c78aec491b8..5cf01438c07 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/json-rpc-engine", - "version": "10.0.2", + "version": "10.0.3", "description": "A tool for processing JSON-RPC messages", "keywords": [ "MetaMask", @@ -58,7 +58,7 @@ "dependencies": { "@metamask/rpc-errors": "^7.0.2", "@metamask/safe-event-emitter": "^3.0.0", - "@metamask/utils": "^11.0.1" + "@metamask/utils": "^11.1.0" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", diff --git a/packages/json-rpc-middleware-stream/CHANGELOG.md b/packages/json-rpc-middleware-stream/CHANGELOG.md index 314c13930b7..b29d8795893 100644 --- a/packages/json-rpc-middleware-stream/CHANGELOG.md +++ b/packages/json-rpc-middleware-stream/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [8.0.7] + +### Changed + +- Bump `@metamask/json-rpc-engine` from `^10.0.2` to `^10.0.3` ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + ## [8.0.6] ### Changed @@ -190,7 +197,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - TypeScript typings ([#11](https://github.com/MetaMask/json-rpc-middleware-stream/pull/11)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@8.0.6...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@8.0.7...HEAD +[8.0.7]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@8.0.6...@metamask/json-rpc-middleware-stream@8.0.7 [8.0.6]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@8.0.5...@metamask/json-rpc-middleware-stream@8.0.6 [8.0.5]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@8.0.4...@metamask/json-rpc-middleware-stream@8.0.5 [8.0.4]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@8.0.3...@metamask/json-rpc-middleware-stream@8.0.4 diff --git a/packages/json-rpc-middleware-stream/package.json b/packages/json-rpc-middleware-stream/package.json index deb17ba1c6d..67caa8ec429 100644 --- a/packages/json-rpc-middleware-stream/package.json +++ b/packages/json-rpc-middleware-stream/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/json-rpc-middleware-stream", - "version": "8.0.6", + "version": "8.0.7", "description": "A small toolset for streaming JSON-RPC data and matching requests and responses", "keywords": [ "MetaMask", @@ -47,9 +47,9 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/json-rpc-engine": "^10.0.2", + "@metamask/json-rpc-engine": "^10.0.3", "@metamask/safe-event-emitter": "^3.0.0", - "@metamask/utils": "^11.0.1", + "@metamask/utils": "^11.1.0", "readable-stream": "^3.6.2" }, "devDependencies": { diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index e67e87c0658..59c0a21205b 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `KeyringController:withKeyring` action ([#5332](https://github.com/MetaMask/core/pull/5332)) + - The action can be used to consume the `withKeyring` method of the `KeyringController` class + +## [19.1.0] + +### Added + +- Add new keyring type for OneKey ([#5216](https://github.com/MetaMask/core/pull/5216)) + +### Changed + +- A specific error message is thrown when any operation is attempted while the controller is locked ([#5172](https://github.com/MetaMask/core/pull/5172)) + +## [19.0.7] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/message-manager` from `^12.0.0` to `^12.0.1` ([#5305](https://github.com/MetaMask/core/pull/5305)) + +## [19.0.6] + +### Changed + +- Bump `@metamask/keyring-api"` from `^16.1.0` to `^17.0.0` ([#5280](https://github.com/MetaMask/core/pull/5280)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + +## [19.0.5] + +### Changed + +- Bump `@metamask/keyring-api` from `^14.0.0` to `^16.1.0` ([#5190](https://github.com/MetaMask/core/pull/5190)), ([#5208](https://github.com/MetaMask/core/pull/5208)) + ## [19.0.4] ### Changed @@ -640,7 +675,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.0.4...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.1.0...HEAD +[19.1.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.0.7...@metamask/keyring-controller@19.1.0 +[19.0.7]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.0.6...@metamask/keyring-controller@19.0.7 +[19.0.6]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.0.5...@metamask/keyring-controller@19.0.6 +[19.0.5]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.0.4...@metamask/keyring-controller@19.0.5 [19.0.4]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.0.3...@metamask/keyring-controller@19.0.4 [19.0.3]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.0.2...@metamask/keyring-controller@19.0.3 [19.0.2]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.0.1...@metamask/keyring-controller@19.0.2 diff --git a/packages/keyring-controller/jest.config.js b/packages/keyring-controller/jest.config.js index 3dbee998978..53a583464ab 100644 --- a/packages/keyring-controller/jest.config.js +++ b/packages/keyring-controller/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 95.51, + branches: 94.26, functions: 100, - lines: 99.07, - statements: 99.08, + lines: 98.96, + statements: 98.98, }, }, diff --git a/packages/keyring-controller/package.json b/packages/keyring-controller/package.json index c3c0cb9c558..406e12d3ad8 100644 --- a/packages/keyring-controller/package.json +++ b/packages/keyring-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/keyring-controller", - "version": "19.0.4", + "version": "19.1.0", "description": "Stores identities seen in the wallet and manages interactions such as signing", "keywords": [ "MetaMask", @@ -49,15 +49,15 @@ "dependencies": { "@ethereumjs/util": "^8.1.0", "@keystonehq/metamask-airgapped-keyring": "^0.14.1", - "@metamask/base-controller": "^7.1.1", + "@metamask/base-controller": "^8.0.0", "@metamask/browser-passworder": "^4.3.0", "@metamask/eth-hd-keyring": "^7.0.4", "@metamask/eth-sig-util": "^8.0.0", "@metamask/eth-simple-keyring": "^6.0.5", - "@metamask/keyring-api": "^14.0.0", - "@metamask/keyring-internal-api": "^2.0.1", - "@metamask/message-manager": "^12.0.0", - "@metamask/utils": "^11.0.1", + "@metamask/keyring-api": "^17.0.0", + "@metamask/keyring-internal-api": "^4.0.1", + "@metamask/message-manager": "^12.0.1", + "@metamask/utils": "^11.1.0", "async-mutex": "^0.5.0", "ethereumjs-wallet": "^1.0.1", "immer": "^9.0.6" diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index 18a0c2319d1..19daf6e49ed 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -2,7 +2,7 @@ import { Chain, Common, Hardfork } from '@ethereumjs/common'; import { TransactionFactory } from '@ethereumjs/tx'; import { CryptoHDKey, ETHSignature } from '@keystonehq/bc-ur-registry-eth'; import { MetaMaskKeyring as QRKeyring } from '@keystonehq/metamask-airgapped-keyring'; -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import HDKeyring from '@metamask/eth-hd-keyring'; import { normalize, @@ -25,13 +25,6 @@ import { import * as sinon from 'sinon'; import * as uuid from 'uuid'; -import MockEncryptor, { - MOCK_ENCRYPTION_KEY, -} from '../tests/mocks/mockEncryptor'; -import { MockErc4337Keyring } from '../tests/mocks/mockErc4337Keyring'; -import { MockKeyring } from '../tests/mocks/mockKeyring'; -import MockShallowGetAccountsKeyring from '../tests/mocks/mockShallowGetAccountsKeyring'; -import { buildMockTransaction } from '../tests/mocks/mockTransaction'; import { KeyringControllerError } from './constants'; import type { KeyringControllerEvents, @@ -47,6 +40,13 @@ import { isCustodyKeyring, keyringBuilderFactory, } from './KeyringController'; +import MockEncryptor, { + MOCK_ENCRYPTION_KEY, +} from '../tests/mocks/mockEncryptor'; +import { MockErc4337Keyring } from '../tests/mocks/mockErc4337Keyring'; +import { MockKeyring } from '../tests/mocks/mockKeyring'; +import MockShallowGetAccountsKeyring from '../tests/mocks/mockShallowGetAccountsKeyring'; +import { buildMockTransaction } from '../tests/mocks/mockTransaction'; jest.mock('uuid', () => { return { @@ -181,29 +181,40 @@ describe('KeyringController', () => { it('should not add a new account if called twice with the same accountCount param', async () => { await withController(async ({ controller, initialState }) => { const accountCount = initialState.keyrings[0].accounts.length; - const firstAccountAdded = await controller.addNewAccount( - accountCount, - ); - const secondAccountAdded = await controller.addNewAccount( - accountCount, - ); + const firstAccountAdded = + await controller.addNewAccount(accountCount); + const secondAccountAdded = + await controller.addNewAccount(accountCount); expect(firstAccountAdded).toBe(secondAccountAdded); expect(controller.state.keyrings[0].accounts).toHaveLength( accountCount + 1, ); }); }); - }); - it('should throw error with no HD keyring', async () => { - await withController( - { skipVaultCreation: true }, - async ({ controller }) => { + it('should throw an error if there is no primary keyring', async () => { + await withController(async ({ controller, encryptor }) => { + await controller.setLocked(); + jest + .spyOn(encryptor, 'decrypt') + .mockResolvedValueOnce([{ type: 'Unsupported', data: '' }]); + await controller.submitPassword('123'); + await expect(controller.addNewAccount()).rejects.toThrow( 'No HD keyring found', ); - }, - ); + }); + }); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.addNewAccount()).rejects.toThrow( + KeyringControllerError.ControllerLocked, + ); + }); }); // Testing fix for bug #4157 {@link https://github.com/MetaMask/core/issues/4157} @@ -220,13 +231,11 @@ describe('KeyringController', () => { const accountCount = initialState.keyrings[0].accounts.length; // We add a new account for "index 1" (not existing yet) - const firstAccountAdded = await controller.addNewAccount( - accountCount, - ); + const firstAccountAdded = + await controller.addNewAccount(accountCount); // Adding an account for an existing index will return the existing account's address - const secondAccountAdded = await controller.addNewAccount( - accountCount, - ); + const secondAccountAdded = + await controller.addNewAccount(accountCount); expect(firstAccountAdded).toBe(secondAccountAdded); expect(controller.state.keyrings[0].accounts).toHaveLength( accountCount + 1, @@ -258,9 +267,8 @@ describe('KeyringController', () => { const [primaryKeyring] = controller.getKeyringsByType( KeyringTypes.hd, ) as Keyring[]; - const addedAccountAddress = await controller.addNewAccountForKeyring( - primaryKeyring, - ); + const addedAccountAddress = + await controller.addNewAccountForKeyring(primaryKeyring); expect(initialState.keyrings).toHaveLength(1); expect(initialState.keyrings[0].accounts).not.toStrictEqual( controller.state.keyrings[0].accounts, @@ -306,9 +314,8 @@ describe('KeyringController', () => { const [primaryKeyring] = controller.getKeyringsByType( KeyringTypes.hd, ) as Keyring[]; - const addedAccountAddress = await controller.addNewAccountForKeyring( - primaryKeyring, - ); + const addedAccountAddress = + await controller.addNewAccountForKeyring(primaryKeyring); expect(initialState.keyrings).toHaveLength(1); expect(initialState.keyrings[0].accounts).not.toStrictEqual( controller.state.keyrings[0].accounts, @@ -359,6 +366,17 @@ describe('KeyringController', () => { }); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + const keyring = controller.getKeyringsByType(KeyringTypes.hd)[0]; + await controller.setLocked(); + + await expect( + controller.addNewAccountForKeyring(keyring as EthKeyring), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); + }); + }); }); describe('addNewKeyring', () => { @@ -382,6 +400,16 @@ describe('KeyringController', () => { }); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.addNewKeyring(KeyringTypes.hd)).rejects.toThrow( + KeyringControllerError.ControllerLocked, + ); + }); + }); }); describe('createNewVaultAndRestore', () => { @@ -407,9 +435,8 @@ describe('KeyringController', () => { await withController( { cacheEncryptionKey }, async ({ controller, initialState }) => { - const currentSeedWord = await controller.exportSeedPhrase( - password, - ); + const currentSeedWord = + await controller.exportSeedPhrase(password); await controller.createNewVaultAndRestore( password, @@ -475,9 +502,8 @@ describe('KeyringController', () => { async ({ controller }) => { await controller.createNewVaultAndKeychain(password); - const currentSeedPhrase = await controller.exportSeedPhrase( - password, - ); + const currentSeedPhrase = + await controller.exportSeedPhrase(password); expect(currentSeedPhrase.length).toBeGreaterThan(0); expect( @@ -565,17 +591,15 @@ describe('KeyringController', () => { await withController( { cacheEncryptionKey }, async ({ controller, initialState }) => { - const initialSeedWord = await controller.exportSeedPhrase( - password, - ); + const initialSeedWord = + await controller.exportSeedPhrase(password); expect(initialSeedWord).toBeDefined(); const initialVault = controller.state.vault; await controller.createNewVaultAndKeychain(password); - const currentSeedWord = await controller.exportSeedPhrase( - password, - ); + const currentSeedWord = + await controller.exportSeedPhrase(password); expect(initialState).toStrictEqual(controller.state); expect(initialSeedWord).toBe(currentSeedWord); expect(initialVault).toStrictEqual(controller.state.vault); @@ -640,6 +664,16 @@ describe('KeyringController', () => { expect(listener.called).toBe(true); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.setLocked()).rejects.toThrow( + KeyringControllerError.ControllerLocked, + ); + }); + }); }); describe('exportSeedPhrase', () => { @@ -682,6 +716,16 @@ describe('KeyringController', () => { }); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.exportSeedPhrase(password)).rejects.toThrow( + KeyringControllerError.ControllerLocked, + ); + }); + }); }); describe('exportAccount', () => { @@ -761,6 +805,16 @@ describe('KeyringController', () => { expect(accounts).toStrictEqual(initialAccount); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.getAccounts()).rejects.toThrow( + KeyringControllerError.ControllerLocked, + ); + }); + }); }); describe('getEncryptionPublicKey', () => { @@ -802,6 +856,18 @@ describe('KeyringController', () => { ); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.getEncryptionPublicKey( + initialState.keyrings[0].accounts[0], + ), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); + }); + }); }); describe('decryptMessage', () => { @@ -878,6 +944,24 @@ describe('KeyringController', () => { ); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.decryptMessage({ + from: initialState.keyrings[0].accounts[0], + data: { + version: '1.0', + nonce: '123456', + ephemPublicKey: '0xabcdef1234567890', + ciphertext: '0xabcdef1234567890', + }, + }), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); + }); + }); }); describe('getKeyringForAccount', () => { @@ -899,7 +983,7 @@ describe('KeyringController', () => { }); describe('when non-existing account is provided', () => { - it('should throw error', async () => { + it('should throw error if no account matches the address', async () => { await withController(async ({ controller }) => { await expect( controller.getKeyringForAccount( @@ -911,19 +995,44 @@ describe('KeyringController', () => { }); }); - it('should throw an error if there are no keyrings', async () => { - await withController( - { skipVaultCreation: true }, - async ({ controller }) => { - await expect( - controller.getKeyringForAccount( - '0x51253087e6f8358b5f10c0a94315d69db3357859', - ), - ).rejects.toThrow( - 'KeyringController - No keyring found. Error info: There are no keyrings', - ); - }, - ); + it('should throw an error if there is no keyring', async () => { + await withController(async ({ controller, encryptor }) => { + await controller.setLocked(); + jest + .spyOn(encryptor, 'decrypt') + .mockResolvedValueOnce([{ type: 'Unsupported', data: '' }]); + await controller.submitPassword('123'); + + await expect( + controller.getKeyringForAccount( + '0x0000000000000000000000000000000000000000', + ), + ).rejects.toThrow( + 'KeyringController - No keyring found. Error info: There are no keyrings', + ); + }); + }); + + it('should throw an error if the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect( + controller.getKeyringForAccount( + '0x51253087e6f8358b5f10c0a94315d69db3357859', + ), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); + }); + }); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.getKeyringForAccount(initialState.keyrings[0].accounts[0]), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); }); }); }); @@ -952,6 +1061,16 @@ describe('KeyringController', () => { }); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + expect(() => controller.getKeyringsByType(KeyringTypes.hd)).toThrow( + KeyringControllerError.ControllerLocked, + ); + }); + }); }); describe('persistAllKeyrings', () => { @@ -973,7 +1092,7 @@ describe('KeyringController', () => { await controller.setLocked(); await expect(controller.persistAllKeyrings()).rejects.toThrow( - KeyringControllerError.MissingCredentials, + KeyringControllerError.ControllerLocked, ); }); }); @@ -1162,6 +1281,19 @@ describe('KeyringController', () => { }); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect( + controller.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [input, 'password'], + ), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); + }); + }); }); describe('removeAccount', () => { @@ -1256,6 +1388,16 @@ describe('KeyringController', () => { ); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.removeAccount(initialState.keyrings[0].accounts[0]), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); + }); + }); }); describe('signMessage', () => { @@ -1319,6 +1461,20 @@ describe('KeyringController', () => { ); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.signMessage({ + from: initialState.keyrings[0].accounts[0], + data: '0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0', + origin: 'https://metamask.github.io', + }), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); + }); + }); }); describe('signPersonalMessage', () => { @@ -1389,6 +1545,20 @@ describe('KeyringController', () => { ); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.signPersonalMessage({ + from: initialState.keyrings[0].accounts[0], + data: '0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0', + origin: 'https://metamask.github.io', + }), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); + }); + }); }); describe('signTypedMessage', () => { @@ -1662,6 +1832,34 @@ describe('KeyringController', () => { ); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.signTypedMessage( + { + from: initialState.keyrings[0].accounts[0], + data: [ + { + type: 'string', + name: 'Message', + value: 'Hi, Alice!', + }, + { + type: 'uint32', + name: 'A number', + value: '1337', + }, + ], + origin: 'https://metamask.github.io', + }, + SignTypedDataVersion.V1, + ), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); + }); + }); }); describe('signTransaction', () => { @@ -1750,6 +1948,19 @@ describe('KeyringController', () => { ); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.signTransaction( + buildMockTransaction(), + initialState.keyrings[0].accounts[0], + ), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); + }); + }); }); describe('prepareUserOperation', () => { @@ -1828,6 +2039,20 @@ describe('KeyringController', () => { ); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.prepareUserOperation( + initialState.keyrings[0].accounts[0], + [], + executionContext, + ), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); + }); + }); }); describe('patchUserOperation', () => { @@ -1915,6 +2140,32 @@ describe('KeyringController', () => { ); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.patchUserOperation( + initialState.keyrings[0].accounts[0], + { + sender: '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4', + nonce: '0x1', + initCode: '0x', + callData: '0x7064', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + paymasterAndData: '0x', + signature: '0x', + }, + executionContext, + ), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); + }); + }); }); describe('signUserOperation', () => { @@ -1999,6 +2250,32 @@ describe('KeyringController', () => { ); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.signUserOperation( + initialState.keyrings[0].accounts[0], + { + sender: '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4', + nonce: '0x1', + initCode: '0x', + callData: '0x7064', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + paymasterAndData: '0x', + signature: '0x', + }, + executionContext, + ), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); + }); + }); }); describe('changePassword', () => { @@ -2028,9 +2305,9 @@ describe('KeyringController', () => { async ({ controller }) => { await controller.setLocked(); - await expect(controller.changePassword('')).rejects.toThrow( - KeyringControllerError.MissingCredentials, - ); + await expect(async () => + controller.changePassword(''), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); }, ); }); @@ -2057,6 +2334,16 @@ describe('KeyringController', () => { }, ); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(async () => + controller.changePassword('whatever'), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); + }); + }); }), ); }); @@ -2275,16 +2562,40 @@ describe('KeyringController', () => { }); }); - it('should throw error with no HD keyring', async () => { + it('should throw error if the controller is locked', async () => { await withController( { skipVaultCreation: true }, async ({ controller }) => { await expect(controller.verifySeedPhrase()).rejects.toThrow( - 'No HD keyring found', + KeyringControllerError.ControllerLocked, ); }, ); }); + + it('should throw an error if there is no primary keyring', async () => { + await withController(async ({ controller, encryptor }) => { + await controller.setLocked(); + jest + .spyOn(encryptor, 'decrypt') + .mockResolvedValueOnce([{ type: 'Unsupported', data: '' }]); + await controller.submitPassword('123'); + + await expect(controller.verifySeedPhrase()).rejects.toThrow( + 'No HD keyring found', + ); + }); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.verifySeedPhrase()).rejects.toThrow( + KeyringControllerError.ControllerLocked, + ); + }); + }); }); describe('verifyPassword', () => { @@ -2467,6 +2778,18 @@ describe('KeyringController', () => { ); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect( + controller.withKeyring({ type: KeyringTypes.hd }, async (keyring) => + keyring.getAccounts(), + ), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); + }); + }); }); describe('QR keyring', () => { @@ -2543,6 +2866,16 @@ describe('KeyringController', () => { expect(qrKeyring).toBeUndefined(); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + expect(() => controller.getQRKeyring()).toThrow( + KeyringControllerError.ControllerLocked, + ); + }); + }); }); describe('connectQRHardware', () => { @@ -2556,21 +2889,18 @@ describe('KeyringController', () => { ), ); - const firstPage = await signProcessKeyringController.connectQRHardware( - 0, - ); + const firstPage = + await signProcessKeyringController.connectQRHardware(0); expect(firstPage).toHaveLength(5); expect(firstPage[0].index).toBe(0); - const secondPage = await signProcessKeyringController.connectQRHardware( - 1, - ); + const secondPage = + await signProcessKeyringController.connectQRHardware(1); expect(secondPage).toHaveLength(5); expect(secondPage[0].index).toBe(5); - const goBackPage = await signProcessKeyringController.connectQRHardware( - -1, - ); + const goBackPage = + await signProcessKeyringController.connectQRHardware(-1); expect(goBackPage).toStrictEqual(firstPage); await signProcessKeyringController.unlockQRHardwareWalletAccount(0); @@ -2582,6 +2912,16 @@ describe('KeyringController', () => { ); expect(qrKeyring?.accounts).toHaveLength(3); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.connectQRHardware(0)).rejects.toThrow( + KeyringControllerError.ControllerLocked, + ); + }); + }); }); describe('signMessage', () => { @@ -2808,6 +3148,16 @@ describe('KeyringController', () => { .sign.request, ).toBeUndefined(); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.resetQRKeyringState()).rejects.toThrow( + KeyringControllerError.ControllerLocked, + ); + }); + }); }); describe('forgetQRDevice', () => { @@ -2838,6 +3188,16 @@ describe('KeyringController', () => { expect(remainingAccounts).toHaveLength(0); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.forgetQRDevice()).rejects.toThrow( + KeyringControllerError.ControllerLocked, + ); + }); + }); }); describe('restoreQRKeyring', () => { @@ -2872,6 +3232,39 @@ describe('KeyringController', () => { signProcessKeyringController.state.keyrings[1].accounts, ).toHaveLength(1); }); + + it('should throw error when the controller is locked', async () => { + const serializedQRKeyring = { + initialized: true, + accounts: ['0xE410157345be56688F43FF0D9e4B2B38Ea8F7828'], + currentAccount: 0, + page: 0, + perPage: 5, + keyringAccount: 'account.standard', + keyringMode: 'hd', + name: 'Keystone', + version: 1, + xfp: '5271c071', + xpub: 'xpub6CNhtuXAHDs84AhZj5ALZB6ii4sP5LnDXaKDSjiy6kcBbiysq89cDrLG29poKvZtX9z4FchZKTjTyiPuDeiFMUd1H4g5zViQxt4tpkronJr', + hdPath: "m/44'/60'/0'", + childrenPath: '0/*', + indexes: { + '0xE410157345be56688F43FF0D9e4B2B38Ea8F7828': 0, + '0xEEACb7a5e53600c144C0b9839A834bb4b39E540c': 1, + '0xA116800A72e56f91cF1677D40C9984f9C9f4B2c7': 2, + '0x4826BadaBC9894B3513e23Be408605611b236C0f': 3, + '0x8a1503beb17Ef02cC4Ff288b0A73583c4ce547c7': 4, + }, + paths: {}, + }; + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect( + controller.restoreQRKeyring(serializedQRKeyring), + ).rejects.toThrow(KeyringControllerError.ControllerLocked); + }); + }); }); describe('getAccountKeyringType', () => { @@ -2888,6 +3281,16 @@ describe('KeyringController', () => { await signProcessKeyringController.getAccountKeyringType(qrAccount), ).toBe(KeyringTypes.qr); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.getAccountKeyringType('0x0')).rejects.toThrow( + KeyringControllerError.ControllerLocked, + ); + }); + }); }); describe('submitQRCryptoHDKey', () => { @@ -2904,6 +3307,16 @@ describe('KeyringController', () => { await signProcessKeyringController.submitQRCryptoHDKey('anything'); expect(submitCryptoHDKeyStub.calledWith('anything')).toBe(true); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.submitQRCryptoHDKey('0x0')).rejects.toThrow( + KeyringControllerError.ControllerLocked, + ); + }); + }); }); describe('submitQRCryptoAccount', () => { @@ -2920,6 +3333,16 @@ describe('KeyringController', () => { await signProcessKeyringController.submitQRCryptoAccount('anything'); expect(submitCryptoAccountStub.calledWith('anything')).toBe(true); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.submitQRCryptoAccount('0x0')).rejects.toThrow( + KeyringControllerError.ControllerLocked, + ); + }); + }); }); describe('submitQRSignature', () => { @@ -3415,6 +3838,28 @@ describe('KeyringController', () => { }); }); }); + + describe('withKeyring', () => { + it('should call withKeyring', async () => { + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller, messenger }) => { + await controller.addNewKeyring(MockKeyring.type); + + const actionReturnValue = await messenger.call( + 'KeyringController:withKeyring', + { type: MockKeyring.type }, + async (keyring) => { + expect(keyring.type).toBe(MockKeyring.type); + return keyring.type; + }, + ); + + expect(actionReturnValue).toBe(MockKeyring.type); + }, + ); + }); + }); }); describe('run conditions', () => { @@ -3538,22 +3983,19 @@ function stubKeyringClassWithAccount( } /** - * Build a controller messenger that includes all events used by the keyring + * Build a messenger that includes all events used by the keyring * controller. * - * @returns The controller messenger. + * @returns The messenger. */ function buildMessenger() { - return new ControllerMessenger< - KeyringControllerActions, - KeyringControllerEvents - >(); + return new Messenger(); } /** - * Build a restricted controller messenger for the keyring controller. + * Build a restricted messenger for the keyring controller. * - * @param messenger - A controller messenger. + * @param messenger - A messenger. * @returns The keyring controller restricted messenger. */ function buildKeyringControllerMessenger(messenger = buildMessenger()) { diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index bf5b853aeeb..55f3acad097 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -4,7 +4,7 @@ import type { MetaMaskKeyring as QRKeyring, IKeyringState as IQRKeyringState, } from '@keystonehq/metamask-airgapped-keyring'; -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; +import type { RestrictedMessenger } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import * as encryptorUtils from '@metamask/browser-passworder'; import HDKeyring from '@metamask/eth-hd-keyring'; @@ -52,32 +52,20 @@ const name = 'KeyringController'; * Available keyring types */ export enum KeyringTypes { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention simple = 'Simple Key Pair', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention hd = 'HD Key Tree', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention qr = 'QR Hardware Wallet Device', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention trezor = 'Trezor Hardware', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + oneKey = 'OneKey Hardware', ledger = 'Ledger Hardware', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention lattice = 'Lattice Hardware', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention snap = 'Snap Keyring', } /** * Custody keyring types are a special case, as they are not a single type * but they all start with the prefix "Custody". + * * @param keyringType - The type of the keyring. * @returns Whether the keyring type is a custody keyring. */ @@ -86,21 +74,31 @@ export const isCustodyKeyring = (keyringType: string): boolean => { }; /** - * @type KeyringControllerState - * - * Keyring controller state - * @property vault - Encrypted string representing keyring data - * @property isUnlocked - Whether vault is unlocked - * @property keyringTypes - Account types - * @property keyrings - Group of accounts - * @property encryptionKey - Keyring encryption key - * @property encryptionSalt - Keyring encryption salt + * The KeyringController state */ export type KeyringControllerState = { + /** + * Encrypted array of serialized keyrings data. + */ vault?: string; + /** + * Whether the vault has been decrypted successfully and + * keyrings contained within are deserialized and available. + */ isUnlocked: boolean; + /** + * Representations of managed keyrings. + */ keyrings: KeyringObject[]; + /** + * The encryption key derived from the password and used to encrypt + * the vault. This is only stored if the `cacheEncryptionKey` option + * is enabled. + */ encryptionKey?: string; + /** + * The salt used to derive the encryption key from the password. + */ encryptionSalt?: string; }; @@ -179,6 +177,11 @@ export type KeyringControllerAddNewAccountAction = { handler: KeyringController['addNewAccount']; }; +export type KeyringControllerWithKeyringAction = { + type: `${typeof name}:withKeyring`; + handler: KeyringController['withKeyring']; +}; + export type KeyringControllerStateChangeEvent = { type: `${typeof name}:stateChange`; payload: [KeyringControllerState, Patch[]]; @@ -218,7 +221,8 @@ export type KeyringControllerActions = | KeyringControllerPrepareUserOperationAction | KeyringControllerPatchUserOperationAction | KeyringControllerSignUserOperationAction - | KeyringControllerAddNewAccountAction; + | KeyringControllerAddNewAccountAction + | KeyringControllerWithKeyringAction; export type KeyringControllerEvents = | KeyringControllerStateChangeEvent @@ -227,7 +231,7 @@ export type KeyringControllerEvents = | KeyringControllerAccountRemovedEvent | KeyringControllerQRKeyringStateChangeEvent; -export type KeyringControllerMessenger = RestrictedControllerMessenger< +export type KeyringControllerMessenger = RestrictedMessenger< typeof name, KeyringControllerActions, KeyringControllerEvents, @@ -251,14 +255,16 @@ export type KeyringControllerOptions = { ); /** - * @type KeyringObject - * - * Keyring object to return in fullUpdate - * @property type - Keyring type - * @property accounts - Associated accounts + * A keyring object representation. */ export type KeyringObject = { + /** + * Accounts associated with the keyring. + */ accounts: string[]; + /** + * Keyring type. + */ type: string; }; @@ -266,11 +272,7 @@ export type KeyringObject = { * A strategy for importing an account */ export enum AccountImportStrategy { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention privateKey = 'privateKey', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention json = 'json', } @@ -404,13 +406,11 @@ export type KeyringSelector = * * @param releaseLock - A function to release the lock. */ -// TODO: Either fix this lint violation or explain why it's necessary to ignore. -// eslint-disable-next-line @typescript-eslint/naming-convention -type MutuallyExclusiveCallback = ({ +type MutuallyExclusiveCallback = ({ releaseLock, }: { releaseLock: MutexInterface.Releaser; -}) => Promise; +}) => Promise; /** * Get builder function for `Keyring` @@ -586,17 +586,17 @@ export class KeyringController extends BaseController< readonly #vaultOperationMutex = new Mutex(); - #keyringBuilders: { (): EthKeyring; type: string }[]; + readonly #keyringBuilders: { (): EthKeyring; type: string }[]; - #keyrings: EthKeyring[]; + readonly #unsupportedKeyrings: SerializedKeyring[]; - #unsupportedKeyrings: SerializedKeyring[]; + readonly #encryptor: GenericEncryptor | ExportableKeyEncryptor; - #password?: string; + readonly #cacheEncryptionKey: boolean; - #encryptor: GenericEncryptor | ExportableKeyEncryptor; + #keyrings: EthKeyring[]; - #cacheEncryptionKey: boolean; + #password?: string; #qrKeyringStateListener?: ( state: ReturnType, @@ -609,7 +609,7 @@ export class KeyringController extends BaseController< * @param options.encryptor - An optional object for defining encryption schemes. * @param options.keyringBuilders - Set a new name for account. * @param options.cacheEncryptionKey - Whether to cache or not encryption key. - * @param options.messenger - A restricted controller messenger. + * @param options.messenger - A restricted messenger. * @param options.state - Initial state to set on this controller. */ constructor(options: KeyringControllerOptions) { @@ -662,6 +662,8 @@ export class KeyringController extends BaseController< * @returns Promise resolving to the added account address. */ async addNewAccount(accountCount?: number): Promise { + this.#assertIsUnlocked(); + return this.#persistOrRollback(async () => { const primaryKeyring = this.getKeyringsByType('HD Key Tree')[0] as | EthKeyring @@ -669,6 +671,7 @@ export class KeyringController extends BaseController< if (!primaryKeyring) { throw new Error('No HD keyring found'); } + const oldAccounts = await primaryKeyring.getAccounts(); if (accountCount && oldAccounts.length !== accountCount) { @@ -707,6 +710,8 @@ export class KeyringController extends BaseController< // We still uses `Hex` here, since we are not using this method when creating // and account using a "Snap Keyring". This function assume the `keyring` is // ethereum compatible, but "Snap Keyring" might not be. + this.#assertIsUnlocked(); + return this.#persistOrRollback(async () => { const oldAccounts = await this.#getAccountsFromKeyrings(); @@ -765,6 +770,7 @@ export class KeyringController extends BaseController< * If there is a pre-existing locked vault, it will be replaced. * * @param password - Password to unlock the new vault. + * @returns Promise resolving when the operation ends successfully. */ async createNewVaultAndKeychain(password: string) { return this.#persistOrRollback(async () => { @@ -789,6 +795,8 @@ export class KeyringController extends BaseController< type: KeyringTypes | string, opts?: unknown, ): Promise { + this.#assertIsUnlocked(); + if (type === KeyringTypes.qr) { return this.getOrAddQRKeyring(); } @@ -825,6 +833,7 @@ export class KeyringController extends BaseController< * @returns Promise resolving to the seed phrase. */ async exportSeedPhrase(password: string): Promise { + this.#assertIsUnlocked(); await this.verifyPassword(password); assertHasUint8ArrayMnemonic(this.#keyrings[0]); return this.#keyrings[0].mnemonic; @@ -856,6 +865,7 @@ export class KeyringController extends BaseController< * @returns A promise resolving to an array of addresses. */ async getAccounts(): Promise { + this.#assertIsUnlocked(); return this.state.keyrings.reduce( (accounts, keyring) => accounts.concat(keyring.accounts), [], @@ -874,6 +884,7 @@ export class KeyringController extends BaseController< account: string, opts?: Record, ): Promise { + this.#assertIsUnlocked(); const address = ethNormalize(account) as Hex; const keyring = (await this.getKeyringForAccount( account, @@ -897,6 +908,7 @@ export class KeyringController extends BaseController< from: string; data: Eip1024EncryptedData; }): Promise { + this.#assertIsUnlocked(); const address = ethNormalize(messageParams.from) as Hex; const keyring = (await this.getKeyringForAccount( address, @@ -919,6 +931,7 @@ export class KeyringController extends BaseController< * @returns Promise resolving to keyring of the `account` if one exists. */ async getKeyringForAccount(account: string): Promise { + this.#assertIsUnlocked(); const address = normalize(account); const candidates = await Promise.all( @@ -958,6 +971,7 @@ export class KeyringController extends BaseController< * @returns An array of keyrings of the given type. */ getKeyringsByType(type: KeyringTypes | string): unknown[] { + this.#assertIsUnlocked(); return this.#keyrings.filter((keyring) => keyring.type === type); } @@ -969,6 +983,7 @@ export class KeyringController extends BaseController< * operation completes. */ async persistAllKeyrings(): Promise { + this.#assertIsUnlocked(); return this.#persistOrRollback(async () => true); } @@ -986,10 +1001,11 @@ export class KeyringController extends BaseController< // eslint-disable-next-line @typescript-eslint/no-explicit-any args: any[], ): Promise { + this.#assertIsUnlocked(); return this.#persistOrRollback(async () => { let privateKey; switch (strategy) { - case 'privateKey': + case AccountImportStrategy.privateKey: const [importedKey] = args; if (!importedKey) { throw new Error('Cannot import an empty key.'); @@ -1013,7 +1029,7 @@ export class KeyringController extends BaseController< privateKey = remove0x(prefixed); break; - case 'json': + case AccountImportStrategy.json: let wallet; const [input, password] = args; try { @@ -1024,7 +1040,7 @@ export class KeyringController extends BaseController< privateKey = bytesToHex(wallet.getPrivateKey()); break; default: - throw new Error(`Unexpected import strategy: '${strategy}'`); + throw new Error(`Unexpected import strategy: '${String(strategy)}'`); } const newKeyring = (await this.#newKeyring(KeyringTypes.simple, [ privateKey, @@ -1042,6 +1058,8 @@ export class KeyringController extends BaseController< * @returns Promise resolving when the account is removed. */ async removeAccount(address: string): Promise { + this.#assertIsUnlocked(); + await this.#persistOrRollback(async () => { const keyring = (await this.getKeyringForAccount( address, @@ -1054,7 +1072,6 @@ export class KeyringController extends BaseController< // The `removeAccount` method of snaps keyring is async. We have to update // the interface of the other keyrings to be async as well. - // eslint-disable-next-line @typescript-eslint/await-thenable // FIXME: We do cast to `Hex` to makes the type checker happy here, and // because `Keyring.removeAccount` requires address to be `Hex`. Those // type would need to be updated for a full non-EVM support. @@ -1076,6 +1093,8 @@ export class KeyringController extends BaseController< * @returns Promise resolving when the operation completes. */ async setLocked(): Promise { + this.#assertIsUnlocked(); + return this.#withRollback(async () => { this.#unsubscribeFromQRKeyringsEvents(); @@ -1100,6 +1119,8 @@ export class KeyringController extends BaseController< * @returns Promise resolving to a signed message string. */ async signMessage(messageParams: PersonalMessageParams): Promise { + this.#assertIsUnlocked(); + if (!messageParams.data) { throw new Error("Can't sign an empty message"); } @@ -1122,6 +1143,7 @@ export class KeyringController extends BaseController< * @returns Promise resolving to a signed message string. */ async signPersonalMessage(messageParams: PersonalMessageParams) { + this.#assertIsUnlocked(); const address = ethNormalize(messageParams.from) as Hex; const keyring = (await this.getKeyringForAccount( address, @@ -1147,6 +1169,8 @@ export class KeyringController extends BaseController< messageParams: TypedMessageParams, version: SignTypedDataVersion, ): Promise { + this.#assertIsUnlocked(); + try { if ( ![ @@ -1196,6 +1220,7 @@ export class KeyringController extends BaseController< from: string, opts?: Record, ): Promise { + this.#assertIsUnlocked(); const address = ethNormalize(from) as Hex; const keyring = (await this.getKeyringForAccount( address, @@ -1220,6 +1245,7 @@ export class KeyringController extends BaseController< transactions: EthBaseTransaction[], executionContext: KeyringExecutionContext, ): Promise { + this.#assertIsUnlocked(); const address = ethNormalize(from) as Hex; const keyring = (await this.getKeyringForAccount( address, @@ -1250,6 +1276,7 @@ export class KeyringController extends BaseController< userOp: EthUserOperation, executionContext: KeyringExecutionContext, ): Promise { + this.#assertIsUnlocked(); const address = ethNormalize(from) as Hex; const keyring = (await this.getKeyringForAccount( address, @@ -1275,6 +1302,7 @@ export class KeyringController extends BaseController< userOp: EthUserOperation, executionContext: KeyringExecutionContext, ): Promise { + this.#assertIsUnlocked(); const address = ethNormalize(from) as Hex; const keyring = (await this.getKeyringForAccount( address, @@ -1294,11 +1322,8 @@ export class KeyringController extends BaseController< * @returns Promise resolving when the operation completes. */ changePassword(password: string): Promise { + this.#assertIsUnlocked(); return this.#persistOrRollback(async () => { - if (!this.state.isUnlocked) { - throw new Error(KeyringControllerError.MissingCredentials); - } - assertIsValidPassword(password); this.#password = password; @@ -1356,6 +1381,7 @@ export class KeyringController extends BaseController< * @returns Promise resolving to the seed phrase as Uint8Array. */ async verifySeedPhrase(): Promise { + this.#assertIsUnlocked(); return this.#withControllerLock(async () => this.#verifySeedPhrase()); } @@ -1425,6 +1451,8 @@ export class KeyringController extends BaseController< createIfMissing: false, }, ): Promise { + this.#assertIsUnlocked(); + return this.#persistOrRollback(async () => { let keyring: SelectedKeyring | undefined; @@ -1472,6 +1500,7 @@ export class KeyringController extends BaseController< * @deprecated Use `withKeyring` instead. */ getQRKeyring(): QRKeyring | undefined { + this.#assertIsUnlocked(); // QRKeyring is not yet compatible with Keyring type from @metamask/utils return this.getKeyringsByType(KeyringTypes.qr)[0] as unknown as QRKeyring; } @@ -1483,6 +1512,8 @@ export class KeyringController extends BaseController< * @deprecated Use `addNewKeyring` and `withKeyring` instead. */ async getOrAddQRKeyring(): Promise { + this.#assertIsUnlocked(); + return ( this.getQRKeyring() || (await this.#persistOrRollback(async () => this.#addQRKeyring())) @@ -1499,6 +1530,8 @@ export class KeyringController extends BaseController< // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any async restoreQRKeyring(serialized: any): Promise { + this.#assertIsUnlocked(); + return this.#persistOrRollback(async () => { const keyring = this.getQRKeyring() || (await this.#addQRKeyring()); keyring.deserialize(serialized); @@ -1512,6 +1545,8 @@ export class KeyringController extends BaseController< * @deprecated Use `withKeyring` instead. */ async resetQRKeyringState(): Promise { + this.#assertIsUnlocked(); + (await this.getOrAddQRKeyring()).resetStore(); } @@ -1523,6 +1558,8 @@ export class KeyringController extends BaseController< * instead. */ async getQRKeyringState(): Promise { + this.#assertIsUnlocked(); + return (await this.getOrAddQRKeyring()).getMemStore(); } @@ -1534,6 +1571,8 @@ export class KeyringController extends BaseController< * @deprecated Use `withKeyring` instead. */ async submitQRCryptoHDKey(cryptoHDKey: string): Promise { + this.#assertIsUnlocked(); + (await this.getOrAddQRKeyring()).submitCryptoHDKey(cryptoHDKey); } @@ -1545,6 +1584,8 @@ export class KeyringController extends BaseController< * @deprecated Use `withKeyring` instead. */ async submitQRCryptoAccount(cryptoAccount: string): Promise { + this.#assertIsUnlocked(); + (await this.getOrAddQRKeyring()).submitCryptoAccount(cryptoAccount); } @@ -1560,6 +1601,8 @@ export class KeyringController extends BaseController< requestId: string, ethSignature: string, ): Promise { + this.#assertIsUnlocked(); + (await this.getOrAddQRKeyring()).submitSignature(requestId, ethSignature); } @@ -1570,6 +1613,8 @@ export class KeyringController extends BaseController< * @deprecated Use `withKeyring` instead. */ async cancelQRSignRequest(): Promise { + this.#assertIsUnlocked(); + (await this.getOrAddQRKeyring()).cancelSignRequest(); } @@ -1580,6 +1625,8 @@ export class KeyringController extends BaseController< * @deprecated Use `withKeyring` instead. */ async cancelQRSynchronization(): Promise { + this.#assertIsUnlocked(); + (await this.getOrAddQRKeyring()).cancelSync(); } @@ -1595,6 +1642,8 @@ export class KeyringController extends BaseController< async connectQRHardware( page: number, ): Promise<{ balance: string; address: string; index: number }[]> { + this.#assertIsUnlocked(); + return this.#persistOrRollback(async () => { try { const keyring = this.getQRKeyring() || (await this.#addQRKeyring()); @@ -1635,6 +1684,8 @@ export class KeyringController extends BaseController< * @deprecated Use `withKeyring` instead. */ async unlockQRHardwareWalletAccount(index: number): Promise { + this.#assertIsUnlocked(); + return this.#persistOrRollback(async () => { const keyring = this.getQRKeyring() || (await this.#addQRKeyring()); @@ -1644,6 +1695,8 @@ export class KeyringController extends BaseController< } async getAccountKeyringType(account: string): Promise { + this.#assertIsUnlocked(); + const keyring = (await this.getKeyringForAccount( account, )) as EthKeyring; @@ -1660,6 +1713,8 @@ export class KeyringController extends BaseController< removedAccounts: string[]; remainingAccounts: string[]; }> { + this.#assertIsUnlocked(); + return this.#persistOrRollback(async () => { const keyring = this.getQRKeyring(); @@ -1747,6 +1802,11 @@ export class KeyringController extends BaseController< `${name}:addNewAccount`, this.addNewAccount.bind(this), ); + + this.messagingSystem.registerActionHandler( + `${name}:withKeyring`, + this.withKeyring.bind(this), + ); } /** @@ -2201,9 +2261,6 @@ export class KeyringController extends BaseController< // NOTE: Not all keyrings implement this method in a asynchronous-way. Using `await` for // non-thenable will still be valid (despite not being really useful). It allows us to cover both // cases and allow retro-compatibility too. - // FIXME: For some reason, it seems that eslint is complaining about this call being non-thenable - // even though it is... For now, we just disable it: - // eslint-disable-next-line @typescript-eslint/await-thenable await keyring.generateRandomMnemonic(); await keyring.addAccounts(1); } @@ -2348,19 +2405,30 @@ export class KeyringController extends BaseController< this.messagingSystem.publish(`${name}:unlock`); } + /** + * Assert that the controller is unlocked. + * + * @throws If the controller is locked. + */ + #assertIsUnlocked(): void { + if (!this.state.isUnlocked) { + throw new Error(KeyringControllerError.ControllerLocked); + } + } + /** * Execute the given function after acquiring the controller lock * and save the keyrings to state after it, or rollback to their * previous state in case of error. * - * @param fn - The function to execute. + * @param callback - The function to execute. * @returns The result of the function. */ - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - async #persistOrRollback(fn: MutuallyExclusiveCallback): Promise { + async #persistOrRollback( + callback: MutuallyExclusiveCallback, + ): Promise { return this.#withRollback(async ({ releaseLock }) => { - const callbackResult = await fn({ releaseLock }); + const callbackResult = await callback({ releaseLock }); // State is committed only if the operation is successful await this.#updateVault(); @@ -2372,18 +2440,18 @@ export class KeyringController extends BaseController< * Execute the given function after acquiring the controller lock * and rollback keyrings and password states in case of error. * - * @param fn - The function to execute atomically. + * @param callback - The function to execute atomically. * @returns The result of the function. */ - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - async #withRollback(fn: MutuallyExclusiveCallback): Promise { + async #withRollback( + callback: MutuallyExclusiveCallback, + ): Promise { return this.#withControllerLock(async ({ releaseLock }) => { const currentSerializedKeyrings = await this.#getSerializedKeyrings(); const currentPassword = this.#password; try { - return await fn({ releaseLock }); + return await callback({ releaseLock }); } catch (e) { // Keyrings and password are restored to their previous state await this.#restoreSerializedKeyrings(currentSerializedKeyrings); @@ -2414,13 +2482,13 @@ export class KeyringController extends BaseController< * controller and that changes its state is executed in a mutually exclusive way, * preventing unsafe concurrent access that could lead to unpredictable behavior. * - * @param fn - The function to execute while the controller mutex is locked. + * @param callback - The function to execute while the controller mutex is locked. * @returns The result of the function. */ - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - async #withControllerLock(fn: MutuallyExclusiveCallback): Promise { - return withLock(this.#controllerOperationMutex, fn); + async #withControllerLock( + callback: MutuallyExclusiveCallback, + ): Promise { + return withLock(this.#controllerOperationMutex, callback); } /** @@ -2431,15 +2499,15 @@ export class KeyringController extends BaseController< * This ensures that each operation that interacts with the vault * is executed in a mutually exclusive way. * - * @param fn - The function to execute while the vault mutex is locked. + * @param callback - The function to execute while the vault mutex is locked. * @returns The result of the function. */ - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - async #withVaultLock(fn: MutuallyExclusiveCallback): Promise { + async #withVaultLock( + callback: MutuallyExclusiveCallback, + ): Promise { this.#assertControllerMutexIsLocked(); - return withLock(this.#vaultOperationMutex, fn); + return withLock(this.#vaultOperationMutex, callback); } } @@ -2449,19 +2517,17 @@ export class KeyringController extends BaseController< * error is thrown. * * @param mutex - The mutex to lock. - * @param fn - The function to execute while the mutex is locked. + * @param callback - The function to execute while the mutex is locked. * @returns The result of the function. */ -// TODO: Either fix this lint violation or explain why it's necessary to ignore. -// eslint-disable-next-line @typescript-eslint/naming-convention -async function withLock( +async function withLock( mutex: Mutex, - fn: MutuallyExclusiveCallback, -): Promise { + callback: MutuallyExclusiveCallback, +): Promise { const releaseLock = await mutex.acquire(); try { - return await fn({ releaseLock }); + return await callback({ releaseLock }); } finally { releaseLock(); } diff --git a/packages/keyring-controller/src/constants.ts b/packages/keyring-controller/src/constants.ts index fe58710cfaa..4da2115a417 100644 --- a/packages/keyring-controller/src/constants.ts +++ b/packages/keyring-controller/src/constants.ts @@ -24,6 +24,7 @@ export enum KeyringControllerError { UnsupportedPatchUserOperation = 'KeyringController - The keyring for the current address does not support the method patchUserOperation.', UnsupportedSignUserOperation = 'KeyringController - The keyring for the current address does not support the method signUserOperation.', NoAccountOnKeychain = "KeyringController - The keychain doesn't have accounts.", + ControllerLocked = 'KeyringController - The operation cannot be completed while the controller is locked.', MissingCredentials = 'KeyringController - Cannot persist vault without password and encryption key', MissingVaultData = 'KeyringController - Cannot persist vault without vault information', ExpiredCredentials = 'KeyringController - Encryption key and salt provided are expired', diff --git a/packages/logging-controller/CHANGELOG.md b/packages/logging-controller/CHANGELOG.md index 4d7b0422a5e..459571c3f1a 100644 --- a/packages/logging-controller/CHANGELOG.md +++ b/packages/logging-controller/CHANGELOG.md @@ -7,9 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [6.0.4] + ### Changed -- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) +- Bump `@metamask/base-controller` from `^7.0.2` to `^8.0.0` ([#5079](https://github.com/MetaMask/core/pull/5079)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5272](https://github.com/MetaMask/core/pull/5272)) ## [6.0.3] @@ -155,7 +158,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial Release - Add logging controller ([#1089](https://github.com/MetaMask/core.git/pull/1089)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@6.0.3...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@6.0.4...HEAD +[6.0.4]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@6.0.3...@metamask/logging-controller@6.0.4 [6.0.3]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@6.0.2...@metamask/logging-controller@6.0.3 [6.0.2]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@6.0.1...@metamask/logging-controller@6.0.2 [6.0.1]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@6.0.0...@metamask/logging-controller@6.0.1 diff --git a/packages/logging-controller/package.json b/packages/logging-controller/package.json index 6c876367298..028df807103 100644 --- a/packages/logging-controller/package.json +++ b/packages/logging-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/logging-controller", - "version": "6.0.3", + "version": "6.0.4", "description": "Manages logging data to assist users and support staff", "keywords": [ "MetaMask", @@ -47,8 +47,8 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/controller-utils": "^11.4.5", + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", "uuid": "^8.3.2" }, "devDependencies": { diff --git a/packages/logging-controller/src/LoggingController.test.ts b/packages/logging-controller/src/LoggingController.test.ts index 3f092f56b4d..929cbb42f98 100644 --- a/packages/logging-controller/src/LoggingController.test.ts +++ b/packages/logging-controller/src/LoggingController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import * as uuid from 'uuid'; import type { LoggingControllerActions } from './LoggingController'; @@ -18,24 +18,22 @@ jest.mock('uuid', () => { const name = 'LoggingController'; /** - * Constructs a unrestricted controller messenger. + * Constructs a unrestricted messenger. * - * @returns A unrestricted controller messenger. + * @returns A unrestricted messenger. */ function getUnrestrictedMessenger() { - return new ControllerMessenger(); + return new Messenger(); } /** - * Constructs a restricted controller messenger. + * Constructs a restricted messenger. * - * @param controllerMessenger - An optional unrestricted messenger - * @returns A restricted controller messenger. + * @param messenger - An optional unrestricted messenger + * @returns A restricted messenger. */ -function getRestrictedMessenger( - controllerMessenger = getUnrestrictedMessenger(), -) { - return controllerMessenger.getRestricted({ +function getRestrictedMessenger(messenger = getUnrestrictedMessenger()) { + return messenger.getRestricted({ name, allowedActions: [], allowedEvents: [], diff --git a/packages/logging-controller/src/LoggingController.ts b/packages/logging-controller/src/LoggingController.ts index 502fbedd4c4..384108d78fa 100644 --- a/packages/logging-controller/src/LoggingController.ts +++ b/packages/logging-controller/src/LoggingController.ts @@ -1,7 +1,7 @@ import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import { v1 as random } from 'uuid'; @@ -54,7 +54,7 @@ export type LoggingControllerStateChangeEvent = ControllerStateChangeEvent< export type LoggingControllerEvents = LoggingControllerStateChangeEvent; -export type LoggingControllerMessenger = RestrictedControllerMessenger< +export type LoggingControllerMessenger = RestrictedMessenger< typeof name, LoggingControllerActions, LoggingControllerEvents, @@ -82,7 +82,7 @@ export class LoggingController extends BaseController< * Creates a LoggingController instance. * * @param options - Constructor options - * @param options.messenger - An instance of the ControllerMessenger + * @param options.messenger - An instance of the Messenger * @param options.state - Initial state to set on this controller. */ constructor({ diff --git a/packages/message-manager/CHANGELOG.md b/packages/message-manager/CHANGELOG.md index 0395cc7f3e6..bf1b24e915d 100644 --- a/packages/message-manager/CHANGELOG.md +++ b/packages/message-manager/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [12.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.0` to `^8.0.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + ## [12.0.0] ### Changed @@ -359,7 +367,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/message-manager@12.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/message-manager@12.0.1...HEAD +[12.0.1]: https://github.com/MetaMask/core/compare/@metamask/message-manager@12.0.0...@metamask/message-manager@12.0.1 [12.0.0]: https://github.com/MetaMask/core/compare/@metamask/message-manager@11.0.3...@metamask/message-manager@12.0.0 [11.0.3]: https://github.com/MetaMask/core/compare/@metamask/message-manager@11.0.2...@metamask/message-manager@11.0.3 [11.0.2]: https://github.com/MetaMask/core/compare/@metamask/message-manager@11.0.1...@metamask/message-manager@11.0.2 diff --git a/packages/message-manager/package.json b/packages/message-manager/package.json index ee76d4fdd2d..e3837f8cc50 100644 --- a/packages/message-manager/package.json +++ b/packages/message-manager/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/message-manager", - "version": "12.0.0", + "version": "12.0.1", "description": "Stores and manages interactions with signing requests", "keywords": [ "MetaMask", @@ -47,10 +47,10 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/controller-utils": "^11.4.5", + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", "@metamask/eth-sig-util": "^8.0.0", - "@metamask/utils": "^11.0.1", + "@metamask/utils": "^11.1.0", "@types/uuid": "^8.3.0", "jsonschema": "^1.4.1", "uuid": "^8.3.2" diff --git a/packages/message-manager/src/AbstractMessageManager.test.ts b/packages/message-manager/src/AbstractMessageManager.test.ts index fcf520854c0..7af79718123 100644 --- a/packages/message-manager/src/AbstractMessageManager.test.ts +++ b/packages/message-manager/src/AbstractMessageManager.test.ts @@ -1,4 +1,4 @@ -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; +import type { RestrictedMessenger } from '@metamask/base-controller'; import { ApprovalType } from '@metamask/controller-utils'; import type { @@ -67,7 +67,7 @@ const MOCK_MESSENGER = { publish: jest.fn(), registerActionHandler: jest.fn(), registerInitialEventPayload: jest.fn(), -} as unknown as RestrictedControllerMessenger< +} as unknown as RestrictedMessenger< 'AbstractMessageManager', never, never, diff --git a/packages/message-manager/src/AbstractMessageManager.ts b/packages/message-manager/src/AbstractMessageManager.ts index 7027acd6862..3b21b85358c 100644 --- a/packages/message-manager/src/AbstractMessageManager.ts +++ b/packages/message-manager/src/AbstractMessageManager.ts @@ -2,7 +2,7 @@ import { BaseController } from '@metamask/base-controller'; import type { ActionConstraint, EventConstraint, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import type { ApprovalType } from '@metamask/controller-utils'; import type { Json } from '@metamask/utils'; @@ -126,7 +126,7 @@ export type AbstractMessageManagerOptions< Event extends EventConstraint, > = { additionalFinishStatuses?: string[]; - messenger: RestrictedControllerMessenger< + messenger: RestrictedMessenger< string, Action, Event | UpdateBadgeEvent, @@ -150,13 +150,7 @@ export abstract class AbstractMessageManager< > extends BaseController< string, MessageManagerState, - RestrictedControllerMessenger< - string, - Action, - Event | UpdateBadgeEvent, - string, - string - > + RestrictedMessenger > { protected messages: Message[]; diff --git a/packages/message-manager/src/DecryptMessageManager.ts b/packages/message-manager/src/DecryptMessageManager.ts index 571ca9e760d..563909d05d5 100644 --- a/packages/message-manager/src/DecryptMessageManager.ts +++ b/packages/message-manager/src/DecryptMessageManager.ts @@ -1,7 +1,7 @@ import type { ActionConstraint, EventConstraint, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { ApprovalType } from '@metamask/controller-utils'; @@ -30,7 +30,7 @@ export type DecryptMessageManagerUpdateBadgeEvent = { payload: []; }; -export type DecryptMessageManagerMessenger = RestrictedControllerMessenger< +export type DecryptMessageManagerMessenger = RestrictedMessenger< string, ActionConstraint, | EventConstraint diff --git a/packages/message-manager/src/EncryptionPublicKeyManager.ts b/packages/message-manager/src/EncryptionPublicKeyManager.ts index 6654bc01329..139282e7c05 100644 --- a/packages/message-manager/src/EncryptionPublicKeyManager.ts +++ b/packages/message-manager/src/EncryptionPublicKeyManager.ts @@ -1,7 +1,7 @@ import type { ActionConstraint, EventConstraint, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { ApprovalType } from '@metamask/controller-utils'; @@ -31,7 +31,7 @@ export type EncryptionPublicKeyManagerUpdateBadgeEvent = { payload: []; }; -export type EncryptionPublicKeyManagerMessenger = RestrictedControllerMessenger< +export type EncryptionPublicKeyManagerMessenger = RestrictedMessenger< string, ActionConstraint, | EventConstraint diff --git a/packages/multichain-network-controller/CHANGELOG.md b/packages/multichain-network-controller/CHANGELOG.md new file mode 100644 index 00000000000..8ee176414d5 --- /dev/null +++ b/packages/multichain-network-controller/CHANGELOG.md @@ -0,0 +1,19 @@ +# 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] + +## [0.1.0] + +### Added + +- Initial release ([#5215](https://github.com/MetaMask/core/pull/5215)) + - Handle both EVM and non-EVM network and account switching for the associated network. + - Act as a proxy for the `NetworkController` (for EVM network changes). + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.1.0...HEAD +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/multichain-network-controller@0.1.0 diff --git a/packages/multichain-network-controller/LICENSE b/packages/multichain-network-controller/LICENSE new file mode 100644 index 00000000000..7d002dced3a --- /dev/null +++ b/packages/multichain-network-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/multichain-network-controller/README.md b/packages/multichain-network-controller/README.md new file mode 100644 index 00000000000..6bdb2c13233 --- /dev/null +++ b/packages/multichain-network-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/multichain-network-controller` + +... + +## Installation + +`yarn add @metamask/multichain-network-controller` + +or + +`npm install @metamask/multichain-network-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/multichain-network-controller/jest.config.js b/packages/multichain-network-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/multichain-network-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/multichain-network-controller/package.json b/packages/multichain-network-controller/package.json new file mode 100644 index 00000000000..cec39a5848e --- /dev/null +++ b/packages/multichain-network-controller/package.json @@ -0,0 +1,81 @@ +{ + "name": "@metamask/multichain-network-controller", + "version": "0.1.0", + "description": "Multichain network controller", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/multichain-network-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:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/multichain-network-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/multichain-network-controller", + "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", + "publish:preview": "yarn npm publish --tag preview" + }, + "dependencies": { + "@metamask/base-controller": "^8.0.0", + "@metamask/keyring-api": "^17.0.0", + "@metamask/utils": "^11.1.0", + "@solana/addresses": "^2.0.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@metamask/keyring-controller": "^19.1.0", + "@metamask/network-controller": "^22.2.1", + "@types/jest": "^27.4.1", + "@types/uuid": "^8.3.0", + "deepmerge": "^4.2.2", + "immer": "^9.0.6", + "jest": "^27.5.1", + "nock": "^13.3.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.2.2" + }, + "peerDependencies": { + "@metamask/accounts-controller": "^24.0.0", + "@metamask/network-controller": "^22.0.0" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/multichain-network-controller/src/MultichainNetworkController.test.ts b/packages/multichain-network-controller/src/MultichainNetworkController.test.ts new file mode 100644 index 00000000000..5f4728054f0 --- /dev/null +++ b/packages/multichain-network-controller/src/MultichainNetworkController.test.ts @@ -0,0 +1,382 @@ +import { Messenger } from '@metamask/base-controller'; +import { InfuraNetworkType } from '@metamask/controller-utils'; +import { + BtcScope, + SolScope, + EthAccountType, + BtcAccountType, + SolAccountType, + type KeyringAccountType, + type CaipChainId, +} from '@metamask/keyring-api'; +import type { + NetworkControllerGetStateAction, + NetworkControllerSetActiveNetworkAction, +} from '@metamask/network-controller'; + +import { getDefaultMultichainNetworkControllerState } from './constants'; +import { MultichainNetworkController } from './MultichainNetworkController'; +import { + type AllowedActions, + type AllowedEvents, + type MultichainNetworkControllerAllowedActions, + type MultichainNetworkControllerAllowedEvents, + MULTICHAIN_NETWORK_CONTROLLER_NAME, +} from './types'; +import { createMockInternalAccount } from '../tests/utils'; + +/** + * Setup a test controller instance. + * + * @param args - Arguments to this function. + * @param args.options - The constructor options for the controller. + * @param args.getNetworkState - Mock for NetworkController:getState action. + * @param args.setActiveNetwork - Mock for NetworkController:setActiveNetwork action. + * @returns A collection of test controllers and mocks. + */ +function setupController({ + options = {}, + getNetworkState, + setActiveNetwork, +}: { + options?: Partial< + ConstructorParameters[0] + >; + getNetworkState?: jest.Mock< + ReturnType, + Parameters + >; + setActiveNetwork?: jest.Mock< + ReturnType, + Parameters + >; +} = {}) { + const messenger = new Messenger< + MultichainNetworkControllerAllowedActions, + MultichainNetworkControllerAllowedEvents + >(); + + const publishSpy = jest.spyOn(messenger, 'publish'); + + // Register action handlers + const mockGetNetworkState = + getNetworkState ?? + jest.fn< + ReturnType, + Parameters + >(); + messenger.registerActionHandler( + 'NetworkController:getState', + mockGetNetworkState, + ); + + const mockSetActiveNetwork = + setActiveNetwork ?? + jest.fn< + ReturnType, + Parameters + >(); + messenger.registerActionHandler( + 'NetworkController:setActiveNetwork', + mockSetActiveNetwork, + ); + + const controllerMessenger = messenger.getRestricted< + typeof MULTICHAIN_NETWORK_CONTROLLER_NAME, + AllowedActions['type'], + AllowedEvents['type'] + >({ + name: MULTICHAIN_NETWORK_CONTROLLER_NAME, + allowedActions: [ + 'NetworkController:setActiveNetwork', + 'NetworkController:getState', + ], + allowedEvents: ['AccountsController:selectedAccountChange'], + }); + + // Default state to use Solana network with EVM as active network + const controller = new MultichainNetworkController({ + messenger: options.messenger || controllerMessenger, + state: { + selectedMultichainNetworkChainId: SolScope.Mainnet, + isEvmSelected: true, + ...options.state, + }, + }); + + const triggerSelectedAccountChange = (accountType: KeyringAccountType) => { + const mockAccountAddressByAccountType: Record = + { + [EthAccountType.Eoa]: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + [EthAccountType.Erc4337]: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + [SolAccountType.DataAccount]: + 'So11111111111111111111111111111111111111112', + [BtcAccountType.P2wpkh]: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + }; + const mockAccountAddress = mockAccountAddressByAccountType[accountType]; + + const mockAccount = createMockInternalAccount({ + type: accountType, + address: mockAccountAddress, + }); + messenger.publish('AccountsController:selectedAccountChange', mockAccount); + }; + + return { + messenger, + controller, + mockGetNetworkState, + mockSetActiveNetwork, + publishSpy, + triggerSelectedAccountChange, + }; +} + +describe('MultichainNetworkController', () => { + describe('constructor', () => { + it('should set default state', () => { + const { controller } = setupController({ + options: { state: getDefaultMultichainNetworkControllerState() }, + }); + expect(controller.state).toStrictEqual( + getDefaultMultichainNetworkControllerState(), + ); + }); + }); + + describe('setActiveNetwork', () => { + it('should set non-EVM network when same non-EVM chain ID is active', async () => { + // By default, Solana is selected but is NOT active (aka EVM network is active) + const { controller, publishSpy } = setupController(); + + // Set active network to Solana + await controller.setActiveNetwork(SolScope.Mainnet); + + // Check that the Solana is now the selected network + expect(controller.state.selectedMultichainNetworkChainId).toBe( + SolScope.Mainnet, + ); + + // Check that the a non evm network is now active + expect(controller.state.isEvmSelected).toBe(false); + + // Check that the messenger published the correct event + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainNetworkController:networkDidChange', + SolScope.Mainnet, + ); + }); + + it('should throw error when unsupported non-EVM chainId is provided', async () => { + const { controller } = setupController(); + const unsupportedChainId = 'eip155:1' as CaipChainId; + + await expect( + controller.setActiveNetwork(unsupportedChainId), + ).rejects.toThrow(`Unsupported Caip chain ID: ${unsupportedChainId}`); + }); + + it('should do nothing when same non-EVM chain ID is set and active', async () => { + // By default, Solana is selected and active + const { controller, publishSpy } = setupController({ + options: { state: { isEvmSelected: false } }, + }); + + // Set active network to Solana + await controller.setActiveNetwork(SolScope.Mainnet); + + expect(controller.state.selectedMultichainNetworkChainId).toBe( + SolScope.Mainnet, + ); + + expect(controller.state.isEvmSelected).toBe(false); + + // Check that the messenger published the correct event + expect(publishSpy).not.toHaveBeenCalled(); + }); + + it('should set non-EVM network when different non-EVM chain ID is active', async () => { + // By default, Solana is selected but is NOT active (aka EVM network is active) + const { controller, publishSpy } = setupController({ + options: { state: { isEvmSelected: false } }, + }); + + // Set active network to Bitcoin + await controller.setActiveNetwork(BtcScope.Mainnet); + + // Check that the Solana is now the selected network + expect(controller.state.selectedMultichainNetworkChainId).toBe( + BtcScope.Mainnet, + ); + + // Check that BTC network is now active + expect(controller.state.isEvmSelected).toBe(false); + + // Check that the messenger published the correct event + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainNetworkController:networkDidChange', + BtcScope.Mainnet, + ); + }); + + it('should set EVM network and call NetworkController:setActiveNetwork when same EVM network is selected', async () => { + const selectedNetworkClientId = InfuraNetworkType.mainnet; + + const { controller, mockSetActiveNetwork, publishSpy } = setupController({ + getNetworkState: jest.fn().mockImplementation(() => ({ + selectedNetworkClientId, + })), + options: { state: { isEvmSelected: false } }, + }); + + // Check that EVM network is not selected + expect(controller.state.isEvmSelected).toBe(false); + + await controller.setActiveNetwork(selectedNetworkClientId); + + // Check that EVM network is selected + expect(controller.state.isEvmSelected).toBe(true); + + // Check that the messenger published the correct event + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainNetworkController:networkDidChange', + selectedNetworkClientId, + ); + + // Check that NetworkController:setActiveNetwork was not called + expect(mockSetActiveNetwork).not.toHaveBeenCalled(); + }); + + it('should set EVM network and call NetworkController:setActiveNetwork when different EVM network is selected', async () => { + const { controller, mockSetActiveNetwork, publishSpy } = setupController({ + getNetworkState: jest.fn().mockImplementation(() => ({ + selectedNetworkClientId: InfuraNetworkType.mainnet, + })), + }); + const evmNetworkClientId = 'linea'; + + await controller.setActiveNetwork(evmNetworkClientId); + + // Check that EVM network is selected + expect(controller.state.isEvmSelected).toBe(true); + + // Check that the messenger published the correct event + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainNetworkController:networkDidChange', + evmNetworkClientId, + ); + + // Check that NetworkController:setActiveNetwork was not called + expect(mockSetActiveNetwork).toHaveBeenCalledWith(evmNetworkClientId); + }); + + it('should not do anything when same EVM network is set and active', async () => { + const { controller, publishSpy } = setupController({ + getNetworkState: jest.fn().mockImplementation(() => ({ + selectedNetworkClientId: InfuraNetworkType.mainnet, + })), + options: { state: { isEvmSelected: true } }, + }); + + // EVM network is already active + expect(controller.state.isEvmSelected).toBe(true); + + await controller.setActiveNetwork(InfuraNetworkType.mainnet); + + // EVM network is still active + expect(controller.state.isEvmSelected).toBe(true); + + // Check that the messenger published the correct event + expect(publishSpy).not.toHaveBeenCalled(); + }); + }); + + describe('handle AccountsController:selectedAccountChange event', () => { + it('isEvmSelected should be true when both switching to EVM account and EVM network is already active', async () => { + // By default, Solana is selected but EVM network is active + const { controller, triggerSelectedAccountChange } = setupController(); + + // EVM network is currently active + expect(controller.state.isEvmSelected).toBe(true); + + // Switching to EVM account + triggerSelectedAccountChange(EthAccountType.Eoa); + + // EVM network is still active + expect(controller.state.isEvmSelected).toBe(true); + }); + + it('should switch to EVM network if non-EVM network is previously active', async () => { + // By default, Solana is selected and active + const { controller, triggerSelectedAccountChange } = setupController({ + options: { state: { isEvmSelected: false } }, + getNetworkState: jest.fn().mockImplementation(() => ({ + selectedNetworkClientId: InfuraNetworkType.mainnet, + })), + }); + + // non-EVM network is currently active + expect(controller.state.isEvmSelected).toBe(false); + + // Switching to EVM account + triggerSelectedAccountChange(EthAccountType.Eoa); + + // EVM network is now active + expect(controller.state.isEvmSelected).toBe(true); + }); + it('non-EVM network should be active when switching to account of same selected non-EVM network', async () => { + // By default, Solana is selected and active + const { controller, triggerSelectedAccountChange } = setupController({ + options: { + state: { + isEvmSelected: true, + selectedMultichainNetworkChainId: SolScope.Mainnet, + }, + }, + }); + + // EVM network is currently active + expect(controller.state.isEvmSelected).toBe(true); + + expect(controller.state.selectedMultichainNetworkChainId).toBe( + SolScope.Mainnet, + ); + + // Switching to Solana account + triggerSelectedAccountChange(SolAccountType.DataAccount); + + // Solana is still the selected network + expect(controller.state.selectedMultichainNetworkChainId).toBe( + SolScope.Mainnet, + ); + expect(controller.state.isEvmSelected).toBe(false); + }); + + it('non-EVM network should change when switching to account on different non-EVM network', async () => { + // By default, Solana is selected and active + const { controller, triggerSelectedAccountChange } = setupController({ + options: { + state: { + isEvmSelected: false, + selectedMultichainNetworkChainId: SolScope.Mainnet, + }, + }, + }); + + // Solana is currently active + expect(controller.state.isEvmSelected).toBe(false); + expect(controller.state.selectedMultichainNetworkChainId).toBe( + SolScope.Mainnet, + ); + + // Switching to Bitcoin account + triggerSelectedAccountChange(BtcAccountType.P2wpkh); + + // Bitcoin is now the selected network + expect(controller.state.selectedMultichainNetworkChainId).toBe( + BtcScope.Mainnet, + ); + expect(controller.state.isEvmSelected).toBe(false); + }); + }); +}); diff --git a/packages/multichain-network-controller/src/MultichainNetworkController.ts b/packages/multichain-network-controller/src/MultichainNetworkController.ts new file mode 100644 index 00000000000..b9c3d5f441b --- /dev/null +++ b/packages/multichain-network-controller/src/MultichainNetworkController.ts @@ -0,0 +1,206 @@ +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 { isCaipChainId } from '@metamask/utils'; + +import { + MULTICHAIN_NETWORK_CONTROLLER_METADATA, + getDefaultMultichainNetworkControllerState, +} from './constants'; +import { + MULTICHAIN_NETWORK_CONTROLLER_NAME, + type MultichainNetworkControllerState, + type MultichainNetworkControllerMessenger, + type SupportedCaipChainId, +} from './types'; +import { + checkIfSupportedCaipChainId, + getChainIdForNonEvmAddress, +} from './utils'; + +/** + * The MultichainNetworkController is responsible for fetching and caching account + * balances. + */ +export class MultichainNetworkController extends BaseController< + typeof MULTICHAIN_NETWORK_CONTROLLER_NAME, + MultichainNetworkControllerState, + MultichainNetworkControllerMessenger +> { + constructor({ + messenger, + state, + }: { + messenger: MultichainNetworkControllerMessenger; + state?: Omit< + Partial, + 'multichainNetworkConfigurationsByChainId' + >; + }) { + super({ + messenger, + name: MULTICHAIN_NETWORK_CONTROLLER_NAME, + metadata: MULTICHAIN_NETWORK_CONTROLLER_METADATA, + state: { + ...getDefaultMultichainNetworkControllerState(), + ...state, + }, + }); + + this.#subscribeToMessageEvents(); + this.#registerMessageHandlers(); + } + + /** + * Sets the active EVM network. + * + * @param id - The client ID of the EVM network to set active. + */ + async #setActiveEvmNetwork(id: NetworkClientId): Promise { + const { selectedNetworkClientId } = this.messagingSystem.call( + 'NetworkController:getState', + ); + + const shouldSetEvmActive = !this.state.isEvmSelected; + const shouldNotifyNetworkChange = id !== selectedNetworkClientId; + + // No changes needed if EVM is active and network is already selected + if (!shouldSetEvmActive && !shouldNotifyNetworkChange) { + return; + } + + // Update EVM selection state if needed + if (shouldSetEvmActive) { + this.update((state) => { + state.isEvmSelected = true; + }); + } + + // Only notify the network controller if the selected evm network is different + if (shouldNotifyNetworkChange) { + await this.messagingSystem.call('NetworkController:setActiveNetwork', id); + } + + // Only publish the networkDidChange event if either the EVM network is different or we're switching between EVM and non-EVM networks + if (shouldSetEvmActive || shouldNotifyNetworkChange) { + this.messagingSystem.publish( + 'MultichainNetworkController:networkDidChange', + id, + ); + } + } + + /** + * Sets the active non-EVM network. + * + * @param id - The chain ID of the non-EVM network to set active. + */ + #setActiveNonEvmNetwork(id: SupportedCaipChainId): void { + if ( + id === this.state.selectedMultichainNetworkChainId && + !this.state.isEvmSelected + ) { + // Same non-EVM network is already selected, no need to update + return; + } + + this.update((state) => { + state.selectedMultichainNetworkChainId = id; + state.isEvmSelected = false; + }); + + // Notify listeners that the network changed + this.messagingSystem.publish( + 'MultichainNetworkController:networkDidChange', + id, + ); + } + + /** + * Sets the active network. + * + * @param id - The non-EVM Caip chain ID or EVM client ID of the network to set active. + * @returns - A promise that resolves when the network is set active. + */ + async setActiveNetwork( + id: SupportedCaipChainId | NetworkClientId, + ): Promise { + if (isCaipChainId(id)) { + const isSupportedCaipChainId = checkIfSupportedCaipChainId(id); + if (!isSupportedCaipChainId) { + throw new Error(`Unsupported Caip chain ID: ${String(id)}`); + } + return this.#setActiveNonEvmNetwork(id); + } + + return await this.#setActiveEvmNetwork(id); + } + + /** + * Handles switching between EVM and non-EVM networks when an account is changed + * + * @param account - The account that was changed + */ + #handleOnSelectedAccountChange(account: InternalAccount) { + const { type: accountType, address: accountAddress } = account; + const isEvmAccount = isEvmAccountType(accountType); + + // Handle switching to EVM network + if (isEvmAccount) { + if (this.state.isEvmSelected) { + // No need to update if already on evm network + return; + } + + // Make EVM network active + this.update((state) => { + state.isEvmSelected = true; + }); + + return; + } + + // Handle switching to non-EVM network + const nonEvmChainId = getChainIdForNonEvmAddress(accountAddress); + const isSameNonEvmNetwork = + nonEvmChainId === this.state.selectedMultichainNetworkChainId; + + if (isSameNonEvmNetwork) { + // No need to update if already on the same non-EVM network + this.update((state) => { + state.isEvmSelected = false; + }); + return; + } + + this.update((state) => { + state.selectedMultichainNetworkChainId = nonEvmChainId; + state.isEvmSelected = false; + }); + + // No need to publish NetworkController:setActiveNetwork because EVM accounts falls back to use the last selected EVM network + // DO NOT publish MultichainNetworkController:networkDidChange to prevent circular listener loops + } + + /** + * Subscribes to message events. + */ + #subscribeToMessageEvents() { + // Handle network switch when account is changed + this.messagingSystem.subscribe( + 'AccountsController:selectedAccountChange', + (account) => this.#handleOnSelectedAccountChange(account), + ); + } + + /** + * Registers message handlers. + */ + #registerMessageHandlers() { + this.messagingSystem.registerActionHandler( + 'MultichainNetworkController:setActiveNetwork', + this.setActiveNetwork.bind(this), + ); + } +} diff --git a/packages/multichain-network-controller/src/constants.ts b/packages/multichain-network-controller/src/constants.ts new file mode 100644 index 00000000000..0f84e75f9b1 --- /dev/null +++ b/packages/multichain-network-controller/src/constants.ts @@ -0,0 +1,74 @@ +import { type StateMetadata } from '@metamask/base-controller'; +import { BtcScope, SolScope } from '@metamask/keyring-api'; +import { NetworkStatus } from '@metamask/network-controller'; + +import type { + MultichainNetworkConfiguration, + MultichainNetworkControllerState, + MultichainNetworkMetadata, + SupportedCaipChainId, +} from './types'; + +export const BTC_NATIVE_ASSET = `${BtcScope.Mainnet}/slip44:0`; +export const SOL_NATIVE_ASSET = `${SolScope.Mainnet}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`; + +/** + * Supported networks by the MultichainNetworkController + */ +export const AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS: Record< + SupportedCaipChainId, + MultichainNetworkConfiguration +> = { + [BtcScope.Mainnet]: { + chainId: BtcScope.Mainnet, + name: 'Bitcoin Mainnet', + nativeCurrency: BTC_NATIVE_ASSET, + isEvm: false, + }, + [SolScope.Mainnet]: { + chainId: SolScope.Mainnet, + name: 'Solana Mainnet', + nativeCurrency: SOL_NATIVE_ASSET, + isEvm: false, + }, +}; + +/** + * Metadata for the supported networks. + */ +export const NETWORKS_METADATA: Record = { + [BtcScope.Mainnet]: { + features: [], + status: NetworkStatus.Available, + }, + [SolScope.Mainnet]: { + features: [], + status: NetworkStatus.Available, + }, +}; + +/** + * Default state of the {@link MultichainNetworkController}. + * + * @returns The default state of the {@link MultichainNetworkController}. + */ +export const getDefaultMultichainNetworkControllerState = + (): MultichainNetworkControllerState => ({ + multichainNetworkConfigurationsByChainId: + AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS, + selectedMultichainNetworkChainId: SolScope.Mainnet, + isEvmSelected: true, + }); + +/** + * {@link MultichainNetworkController}'s metadata. + * + * This allows us to choose if fields of the state should be persisted or not + * using the `persist` flag; and if they can be sent to Sentry or not, using + * the `anonymous` flag. + */ +export const MULTICHAIN_NETWORK_CONTROLLER_METADATA = { + multichainNetworkConfigurationsByChainId: { persist: true, anonymous: true }, + selectedMultichainNetworkChainId: { persist: true, anonymous: true }, + isEvmSelected: { persist: true, anonymous: true }, +} satisfies StateMetadata; diff --git a/packages/multichain-network-controller/src/index.ts b/packages/multichain-network-controller/src/index.ts new file mode 100644 index 00000000000..eaf8accddf0 --- /dev/null +++ b/packages/multichain-network-controller/src/index.ts @@ -0,0 +1,24 @@ +export { MultichainNetworkController } from './MultichainNetworkController'; +export { getDefaultMultichainNetworkControllerState } from './constants'; +export type { + MultichainNetworkMetadata, + SupportedCaipChainId, + CommonNetworkConfiguration, + NonEvmNetworkConfiguration, + EvmNetworkConfiguration, + MultichainNetworkConfiguration, + MultichainNetworkControllerState, + MultichainNetworkControllerGetStateAction, + MultichainNetworkControllerSetActiveNetworkAction, + MultichainNetworkControllerStateChange, + MultichainNetworkControllerNetworkDidChangeEvent, + MultichainNetworkControllerActions, + MultichainNetworkControllerEvents, + MultichainNetworkControllerMessenger, +} from './types'; +export { + checkIfSupportedCaipChainId, + toMultichainNetworkConfiguration, + toMultichainNetworkConfigurationsByChainId, + toEvmCaipChainId, +} from './utils'; diff --git a/packages/multichain-network-controller/src/types.ts b/packages/multichain-network-controller/src/types.ts new file mode 100644 index 00000000000..5eb1215da2a --- /dev/null +++ b/packages/multichain-network-controller/src/types.ts @@ -0,0 +1,178 @@ +import { + type ControllerGetStateAction, + type ControllerStateChangeEvent, + type RestrictedMessenger, +} from '@metamask/base-controller'; +import type { BtcScope, CaipChainId, SolScope } from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { + NetworkStatus, + NetworkControllerSetActiveNetworkAction, + NetworkControllerGetStateAction, + NetworkClientId, +} from '@metamask/network-controller'; +import { type CaipAssetType } from '@metamask/utils'; + +export const MULTICHAIN_NETWORK_CONTROLLER_NAME = 'MultichainNetworkController'; + +export type MultichainNetworkMetadata = { + features: string[]; + status: NetworkStatus; +}; + +export type SupportedCaipChainId = SolScope.Mainnet | BtcScope.Mainnet; + +export type CommonNetworkConfiguration = { + /** + * EVM network flag. + */ + isEvm: boolean; + /** + * The chain ID of the network. + */ + chainId: CaipChainId; + /** + * The name of the network. + */ + name: string; +}; + +export type NonEvmNetworkConfiguration = CommonNetworkConfiguration & { + /** + * EVM network flag. + */ + isEvm: false; + /** + * The native asset type of the network. + */ + nativeCurrency: CaipAssetType; +}; + +// TODO: The controller only supports non-EVM network configurations at the moment +// Once we support Caip chain IDs for EVM networks, we can re-enable EVM network configurations +export type EvmNetworkConfiguration = CommonNetworkConfiguration & { + /** + * EVM network flag. + */ + isEvm: true; + /** + * The native asset type of the network. + * For EVM, this is the network ticker since there is no standard between + * tickers and Caip IDs. + */ + nativeCurrency: string; + /** + * The block explorers of the network. + */ + blockExplorerUrls: string[]; + /** + * The index of the default block explorer URL. + */ + defaultBlockExplorerUrlIndex: number; +}; + +export type MultichainNetworkConfiguration = + | EvmNetworkConfiguration + | NonEvmNetworkConfiguration; + +/** + * State used by the {@link MultichainNetworkController} to cache network configurations. + */ +export type MultichainNetworkControllerState = { + /** + * The network configurations by chain ID. + */ + multichainNetworkConfigurationsByChainId: Record< + CaipChainId, + MultichainNetworkConfiguration + >; + /** + * The chain ID of the selected network. + */ + selectedMultichainNetworkChainId: SupportedCaipChainId; + /** + * Whether EVM or non-EVM network is selected + */ + isEvmSelected: boolean; +}; + +/** + * Returns the state of the {@link MultichainNetworkController}. + */ +export type MultichainNetworkControllerGetStateAction = + ControllerGetStateAction< + typeof MULTICHAIN_NETWORK_CONTROLLER_NAME, + MultichainNetworkControllerState + >; + +export type SetActiveNetworkMethod = ( + id: SupportedCaipChainId | NetworkClientId, +) => Promise; + +export type MultichainNetworkControllerSetActiveNetworkAction = { + type: `${typeof MULTICHAIN_NETWORK_CONTROLLER_NAME}:setActiveNetwork`; + handler: SetActiveNetworkMethod; +}; + +/** + * Event emitted when the state of the {@link MultichainNetworkController} changes. + */ +export type MultichainNetworkControllerStateChange = ControllerStateChangeEvent< + typeof MULTICHAIN_NETWORK_CONTROLLER_NAME, + MultichainNetworkControllerState +>; + +export type MultichainNetworkControllerNetworkDidChangeEvent = { + type: `${typeof MULTICHAIN_NETWORK_CONTROLLER_NAME}:networkDidChange`; + payload: [NetworkClientId | SupportedCaipChainId]; +}; + +/** + * Actions exposed by the {@link MultichainNetworkController}. + */ +export type MultichainNetworkControllerActions = + | MultichainNetworkControllerGetStateAction + | MultichainNetworkControllerSetActiveNetworkAction; + +/** + * Events emitted by {@link MultichainNetworkController}. + */ +export type MultichainNetworkControllerEvents = + MultichainNetworkControllerNetworkDidChangeEvent; + +/** + * Actions that this controller is allowed to call. + */ +export type AllowedActions = + | NetworkControllerGetStateAction + | NetworkControllerSetActiveNetworkAction; + +// Re-define event here to avoid circular dependency with AccountsController +export type AccountsControllerSelectedAccountChangeEvent = { + type: `AccountsController:selectedAccountChange`; + payload: [InternalAccount]; +}; + +/** + * Events that this controller is allowed to subscribe. + */ +export type AllowedEvents = AccountsControllerSelectedAccountChangeEvent; + +export type MultichainNetworkControllerAllowedActions = + | MultichainNetworkControllerActions + | AllowedActions; + +export type MultichainNetworkControllerAllowedEvents = + | MultichainNetworkControllerEvents + | AllowedEvents; + +/** + * Messenger type for the MultichainNetworkController. + */ +export type MultichainNetworkControllerMessenger = RestrictedMessenger< + typeof MULTICHAIN_NETWORK_CONTROLLER_NAME, + MultichainNetworkControllerAllowedActions, + MultichainNetworkControllerAllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; diff --git a/packages/multichain-network-controller/src/utils.test.ts b/packages/multichain-network-controller/src/utils.test.ts new file mode 100644 index 00000000000..dbf6e5e5322 --- /dev/null +++ b/packages/multichain-network-controller/src/utils.test.ts @@ -0,0 +1,114 @@ +import { BtcScope, SolScope, type CaipChainId } from '@metamask/keyring-api'; +import { type NetworkConfiguration } from '@metamask/network-controller'; + +import { + toEvmCaipChainId, + getChainIdForNonEvmAddress, + checkIfSupportedCaipChainId, + toMultichainNetworkConfiguration, + toMultichainNetworkConfigurationsByChainId, +} from './utils'; + +describe('utils', () => { + describe('getChainIdForNonEvmAddress', () => { + it('returns Solana chain ID for Solana addresses', () => { + const solanaAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + expect(getChainIdForNonEvmAddress(solanaAddress)).toBe(SolScope.Mainnet); + }); + + it('returns Bitcoin chain ID for non-Solana addresses', () => { + const bitcoinAddress = 'bc1qzqc2aqlw8nwa0a05ehjkk7dgt8308ac7kzw9a6'; + expect(getChainIdForNonEvmAddress(bitcoinAddress)).toBe(BtcScope.Mainnet); + }); + }); + + describe('checkIfSupportedCaipChainId', () => { + it('returns true for supported CAIP chain IDs', () => { + expect(checkIfSupportedCaipChainId(SolScope.Mainnet)).toBe(true); + expect(checkIfSupportedCaipChainId(BtcScope.Mainnet)).toBe(true); + }); + + it('returns false for non-CAIP IDs', () => { + expect(checkIfSupportedCaipChainId('mainnet' as CaipChainId)).toBe(false); + }); + + it('returns false for unsupported CAIP chain IDs', () => { + expect(checkIfSupportedCaipChainId('eip155:1')).toBe(false); + }); + }); + + describe('toMultichainNetworkConfiguration', () => { + it('updates the network configuration for a single EVM network', () => { + const network: NetworkConfiguration = { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + rpcEndpoints: [], + defaultRpcEndpointIndex: 0, + }; + expect(toMultichainNetworkConfiguration(network)).toStrictEqual({ + chainId: 'eip155:1', + isEvm: true, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + }); + }); + }); + + describe('toMultichainNetworkConfigurationsByChainId', () => { + it('updates the network configurations for multiple EVM networks', () => { + const networks: Record = { + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + rpcEndpoints: [], + defaultRpcEndpointIndex: 0, + }, + '0xe708': { + chainId: '0xe708', + name: 'Linea', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://lineascan.build'], + defaultBlockExplorerUrlIndex: 0, + rpcEndpoints: [], + defaultRpcEndpointIndex: 0, + }, + }; + expect( + toMultichainNetworkConfigurationsByChainId(networks), + ).toStrictEqual({ + 'eip155:1': { + chainId: 'eip155:1', + isEvm: true, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + }, + 'eip155:59144': { + chainId: 'eip155:59144', + isEvm: true, + name: 'Linea', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://lineascan.build'], + defaultBlockExplorerUrlIndex: 0, + }, + }); + }); + }); + + describe('toEvmCaipChainId', () => { + it('converts a hex chain ID to a CAIP chain ID', () => { + expect(toEvmCaipChainId('0x1')).toBe('eip155:1'); + expect(toEvmCaipChainId('0xe708')).toBe('eip155:59144'); + expect(toEvmCaipChainId('0x539')).toBe('eip155:1337'); + }); + }); +}); diff --git a/packages/multichain-network-controller/src/utils.ts b/packages/multichain-network-controller/src/utils.ts new file mode 100644 index 00000000000..d6a00d7160e --- /dev/null +++ b/packages/multichain-network-controller/src/utils.ts @@ -0,0 +1,93 @@ +import { BtcScope, SolScope } from '@metamask/keyring-api'; +import type { NetworkConfiguration } from '@metamask/network-controller'; +import { + type Hex, + type CaipChainId, + KnownCaipNamespace, + toCaipChainId, + hexToNumber, +} from '@metamask/utils'; +import { isAddress as isSolanaAddress } from '@solana/addresses'; + +import { AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS } from './constants'; +import type { + SupportedCaipChainId, + MultichainNetworkConfiguration, +} from './types'; + +/** + * Returns the chain id of the non-EVM network based on the account address. + * + * @param address - The address to check. + * @returns The caip chain id of the non-EVM network. + */ +export function getChainIdForNonEvmAddress( + address: string, +): SupportedCaipChainId { + // This condition is not the most robust. Once we support more networks, we will need to update this logic. + if (isSolanaAddress(address)) { + return SolScope.Mainnet; + } + return BtcScope.Mainnet; +} + +/** + * Checks if the Caip chain ID is supported. + * + * @param id - The Caip chain IDto check. + * @returns Whether the chain ID is supported. + */ +export function checkIfSupportedCaipChainId( + id: CaipChainId, +): id is SupportedCaipChainId { + // Check if the chain id is supported + return Object.keys(AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS).includes(id); +} + +/** + * Converts a hex chain ID to a Caip chain ID. + * + * @param chainId - The hex chain ID to convert. + * @returns The Caip chain ID. + */ +export const toEvmCaipChainId = (chainId: Hex): CaipChainId => + toCaipChainId(KnownCaipNamespace.Eip155, hexToNumber(chainId).toString()); + +/** + * Updates a network configuration to the format used by the MultichainNetworkController. + * This method is exclusive for EVM networks with hex identifiers from the NetworkController. + * + * @param network - The network configuration to update. + * @returns The updated network configuration. + */ +export const toMultichainNetworkConfiguration = ( + network: NetworkConfiguration, +): MultichainNetworkConfiguration => { + return { + chainId: toEvmCaipChainId(network.chainId), + isEvm: true, + name: network.name, + nativeCurrency: network.nativeCurrency, + blockExplorerUrls: network.blockExplorerUrls, + defaultBlockExplorerUrlIndex: network.defaultBlockExplorerUrlIndex || 0, + }; +}; + +/** + * Updates a record of network configurations to the format used by the MultichainNetworkController. + * This method is exclusive for EVM networks with hex identifiers from the NetworkController. + * + * @param networkConfigurationsByChainId - The network configurations to update. + * @returns The updated network configurations. + */ +export const toMultichainNetworkConfigurationsByChainId = ( + networkConfigurationsByChainId: Record, +): Record => + Object.entries(networkConfigurationsByChainId).reduce( + (acc, [, network]) => ({ + ...acc, + [toEvmCaipChainId(network.chainId)]: + toMultichainNetworkConfiguration(network), + }), + {}, + ); diff --git a/packages/multichain-network-controller/tests/utils.ts b/packages/multichain-network-controller/tests/utils.ts new file mode 100644 index 00000000000..141f6f29f9e --- /dev/null +++ b/packages/multichain-network-controller/tests/utils.ts @@ -0,0 +1,98 @@ +import { + BtcAccountType, + EthAccountType, + SolAccountType, + BtcMethod, + EthMethod, + SolMethod, + type KeyringAccountType, +} from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +/** + * Creates a mock internal account. This is a duplicated function from the accounts-controller package + * This exists here to prevent circular dependencies with the accounts-controller package + * + * @param args - Arguments to this function. + * @param args.id - The ID of the account. + * @param args.address - The address of the account. + * @param args.type - The type of the account. + * @param args.name - The name of the account. + * @param args.keyringType - The keyring type of the account. + * @param args.snap - The snap of the account. + * @param args.snap.id - The ID of the snap. + * @param args.snap.enabled - Whether the snap is enabled. + * @param args.snap.name - The name of the snap. + * @param args.importTime - The import time of the account. + * @param args.lastSelected - The last selected time of the account. + * @returns A mock internal account. + */ +export const createMockInternalAccount = ({ + id = 'dummy-id', + address = '0x2990079bcdee240329a520d2444386fc119da21a', + type = EthAccountType.Eoa, + name = 'Account 1', + keyringType = KeyringTypes.hd, + snap, + importTime = Date.now(), + lastSelected = Date.now(), +}: { + id?: string; + address?: string; + type?: KeyringAccountType; + name?: string; + keyringType?: KeyringTypes; + snap?: { + id: string; + enabled: boolean; + name: string; + }; + importTime?: number; + lastSelected?: number; +} = {}): InternalAccount => { + let methods; + + switch (type) { + case EthAccountType.Eoa: + methods = [ + EthMethod.PersonalSign, + EthMethod.Sign, + EthMethod.SignTransaction, + EthMethod.SignTypedDataV1, + EthMethod.SignTypedDataV3, + EthMethod.SignTypedDataV4, + ]; + break; + case EthAccountType.Erc4337: + methods = [ + EthMethod.PatchUserOperation, + EthMethod.PrepareUserOperation, + EthMethod.SignUserOperation, + ]; + break; + case BtcAccountType.P2wpkh: + methods = [BtcMethod.SendBitcoin]; + break; + case SolAccountType.DataAccount: + methods = [SolMethod.SendAndConfirmTransaction]; + break; + default: + throw new Error(`Unknown account type: ${type as string}`); + } + + return { + id, + address, + options: {}, + methods, + type, + metadata: { + name, + keyring: { type: keyringType }, + importTime, + lastSelected, + snap, + }, + } as InternalAccount; +}; diff --git a/packages/multichain-network-controller/tsconfig.build.json b/packages/multichain-network-controller/tsconfig.build.json new file mode 100644 index 00000000000..41c2d082d3d --- /dev/null +++ b/packages/multichain-network-controller/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../network-controller/tsconfig.build.json" }, + { "path": "../keyring-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/multichain-network-controller/tsconfig.json b/packages/multichain-network-controller/tsconfig.json new file mode 100644 index 00000000000..e5ff777b642 --- /dev/null +++ b/packages/multichain-network-controller/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../network-controller" }, + { "path": "../keyring-controller" } + ], + "include": ["../../types", "./src", "./tests"] +} diff --git a/packages/multichain-network-controller/typedoc.json b/packages/multichain-network-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/multichain-network-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/multichain-transactions-controller/CHANGELOG.md b/packages/multichain-transactions-controller/CHANGELOG.md index 6ce77f400e2..55ca0ede115 100644 --- a/packages/multichain-transactions-controller/CHANGELOG.md +++ b/packages/multichain-transactions-controller/CHANGELOG.md @@ -7,11 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^23.0.0` to `^24.0.0` ([#5318](https://github.com/MetaMask/core/pull/5318)) + +## [0.3.0] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/polling-controller` from `^12.0.2` to `^12.0.3` ([#5305](https://github.com/MetaMask/core/pull/5305)) + +### Removed + +- **BREAKING:** Remove `NETWORK_ASSETS_MAP`, `MultichainNetwork` and `MultichainNativeAsset` from exports, making them no longer available for consumers ([#5295](https://github.com/MetaMask/core/pull/5295)) + +## [0.2.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^22.0.0` to `^23.0.0` ([#5292](https://github.com/MetaMask/core/pull/5292)) +- **BREAKING:** Bump `@metamask/snaps-controllers` peer dependency from `^9.10.0` to `^9.19.0` ([#5265](https://github.com/MetaMask/core/pull/5265)) +- Bump `@metamask/snaps-sdk` from `^6.7.0` to `^6.17.1` ([#5220](https://github.com/MetaMask/core/pull/5220)), ([#5265](https://github.com/MetaMask/core/pull/5265)) +- Bump `@metamask/snaps-utils` from `^8.9.0` to `^8.10.0` ([#5265](https://github.com/MetaMask/core/pull/5265)) +- Bump `@metamask/snaps-controllers` from `^9.10.0` to `^9.19.0` ([#5265](https://github.com/MetaMask/core/pull/5265)) +- Bump `@metamask/keyring-api"` from `^16.1.0` to `^17.0.0` ([#5280](https://github.com/MetaMask/core/pull/5280)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) +- Removed polling mechanism and now relies on the new `AccountsController:accountTransactionsUpdated` event ([#5221](https://github.com/MetaMask/core/pull/5221)) + +## [0.1.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^21.0.0` to `^22.0.0` ([#5218](https://github.com/MetaMask/core/pull/5218)) +- Bump `@metamask/keyring-api` from `^14.0.0` to `^16.1.0` ([#5190](https://github.com/MetaMask/core/pull/5190)), ([#5208](https://github.com/MetaMask/core/pull/5208)) +- Bump `@metamask/keyring-internal-api` from `^2.0.1` to `^4.0.1` ([#5190](https://github.com/MetaMask/core/pull/5190)), ([#5208](https://github.com/MetaMask/core/pull/5208)) +- Bump `@metamask/keyring-snap-client` from `^3.0.0` to `^3.0.3` ([#5190](https://github.com/MetaMask/core/pull/5190)), ([#5208](https://github.com/MetaMask/core/pull/5208)) + ## [0.0.1] ### Added - Initial release ([#5133](https://github.com/MetaMask/core/pull/5133)), ([#5177](https://github.com/MetaMask/core/pull/5177)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.4.0...HEAD +[0.4.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.3.0...@metamask/multichain-transactions-controller@0.4.0 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.2.0...@metamask/multichain-transactions-controller@0.3.0 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.1.0...@metamask/multichain-transactions-controller@0.2.0 +[0.1.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.0.1...@metamask/multichain-transactions-controller@0.1.0 [0.0.1]: https://github.com/MetaMask/core/releases/tag/@metamask/multichain-transactions-controller@0.0.1 diff --git a/packages/multichain-transactions-controller/jest.config.js b/packages/multichain-transactions-controller/jest.config.js index a6493bc83d5..ca084133399 100644 --- a/packages/multichain-transactions-controller/jest.config.js +++ b/packages/multichain-transactions-controller/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 95, - functions: 97, - lines: 97, - statements: 97, + branches: 100, + functions: 100, + lines: 100, + statements: 100, }, }, }); diff --git a/packages/multichain-transactions-controller/package.json b/packages/multichain-transactions-controller/package.json index 3151da93d08..e5cc49b35f4 100644 --- a/packages/multichain-transactions-controller/package.json +++ b/packages/multichain-transactions-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/multichain-transactions-controller", - "version": "0.0.1", + "version": "0.4.0", "description": "This package is responsible for getting transactions from our Bitcoin and Solana snaps", "keywords": [ "MetaMask", @@ -47,23 +47,23 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/keyring-api": "^14.0.0", - "@metamask/keyring-internal-api": "^2.0.1", - "@metamask/keyring-snap-client": "^3.0.0", - "@metamask/polling-controller": "^12.0.2", - "@metamask/snaps-controllers": "^9.10.0", - "@metamask/snaps-sdk": "^6.7.0", - "@metamask/snaps-utils": "^8.3.0", - "@metamask/utils": "^11.0.1", + "@metamask/base-controller": "^8.0.0", + "@metamask/keyring-api": "^17.0.0", + "@metamask/keyring-internal-api": "^4.0.1", + "@metamask/keyring-snap-client": "^3.0.3", + "@metamask/polling-controller": "^12.0.3", + "@metamask/snaps-controllers": "^9.19.0", + "@metamask/snaps-sdk": "^6.17.1", + "@metamask/snaps-utils": "^8.10.0", + "@metamask/utils": "^11.1.0", "@types/uuid": "^8.3.0", "immer": "^9.0.6", "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/accounts-controller": "^21.0.2", + "@metamask/accounts-controller": "^24.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^19.0.4", + "@metamask/keyring-controller": "^19.1.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", @@ -73,8 +73,8 @@ "typescript": "~5.2.2" }, "peerDependencies": { - "@metamask/accounts-controller": "^21.0.0", - "@metamask/snaps-controllers": "^9.10.0" + "@metamask/accounts-controller": "^24.0.0", + "@metamask/snaps-controllers": "^9.19.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 a85e24db68a..8a02a1154aa 100644 --- a/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts @@ -1,15 +1,20 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import type { CaipAssetType, Transaction } from '@metamask/keyring-api'; import { BtcAccountType, BtcMethod, EthAccountType, EthMethod, + SolAccountType, + SolMethod, + SolScope, } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { CaipChainId } from '@metamask/utils'; import { v4 as uuidv4 } from 'uuid'; +import { MultichainNetwork } from './constants'; import { MultichainTransactionsController, getDefaultMultichainTransactionsControllerState, @@ -18,7 +23,6 @@ import { type MultichainTransactionsControllerState, type MultichainTransactionsControllerMessenger, } from './MultichainTransactionsController'; -import { MultichainTransactionsTracker } from './MultichainTransactionsTracker'; const mockBtcAccount = { address: 'bc1qssdcp5kvwh6nghzg9tuk99xsflwkdv4hgvq58q', @@ -42,6 +46,28 @@ const mockBtcAccount = { scopes: [], }; +const mockSolAccount = { + address: 'EBBYfhQzVzurZiweJ2keeBWpgGLs1cbWYcz28gjGgi5x', + id: uuidv4(), + metadata: { + name: 'Solana Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-sol-snap', + name: 'mock-sol-snap', + enabled: true, + }, + lastSelected: 0, + }, + scopes: [SolScope.Devnet], + options: {}, + methods: [SolMethod.SendAndConfirmTransaction], + type: SolAccountType.DataAccount, +}; + const mockEthAccount = { address: '0x807dE1cf8f39E83258904b2f7b473E5C506E4aC1', id: uuidv4(), @@ -69,16 +95,26 @@ const mockTransactionResult = { { id: '123', account: mockBtcAccount.id, - chain: 'bip122:000000000019d6689c085ae165831e93', - type: 'send', - status: 'confirmed', + chain: 'bip122:000000000019d6689c085ae165831e93' as CaipChainId, + type: 'send' as const, + status: 'confirmed' as const, timestamp: Date.now(), - from: [], - to: [], - fees: [], + from: [{ address: 'from-address', asset: null }], + to: [{ address: 'to-address', asset: null }], + fees: [ + { + type: 'base' as const, + asset: { + unit: 'BTC', + type: 'bip122:000000000019d6689c085ae165831e93/slip44:0' as CaipAssetType, + amount: '1000', + fungible: true as const, + }, + }, + ], events: [ { - status: 'confirmed', + status: 'confirmed' as const, timestamp: Date.now(), }, ], @@ -97,13 +133,10 @@ const setupController = ({ handleRequestReturnValue?: Record; }; } = {}) => { - const controllerMessenger = new ControllerMessenger< - AllowedActions, - AllowedEvents - >(); + const messenger = new Messenger(); const multichainTransactionsControllerMessenger: MultichainTransactionsControllerMessenger = - controllerMessenger.getRestricted({ + messenger.getRestricted({ name: 'MultichainTransactionsController', allowedActions: [ 'SnapController:handleRequest', @@ -112,11 +145,12 @@ const setupController = ({ allowedEvents: [ 'AccountsController:accountAdded', 'AccountsController:accountRemoved', + 'AccountsController:accountTransactionsUpdated', ], }); const mockSnapHandleRequest = jest.fn(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'SnapController:handleRequest', mockSnapHandleRequest.mockReturnValue( mocks?.handleRequestReturnValue ?? mockTransactionResult, @@ -124,7 +158,7 @@ const setupController = ({ ); const mockListMultichainAccounts = jest.fn(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'AccountsController:listMultichainAccounts', mockListMultichainAccounts.mockReturnValue( mocks?.listMultichainAccounts ?? [mockBtcAccount, mockEthAccount], @@ -138,56 +172,33 @@ const setupController = ({ return { controller, - messenger: controllerMessenger, + messenger, mockSnapHandleRequest, mockListMultichainAccounts, }; }; +/** + * Utility function that waits for all pending promises to be resolved. + * This is necessary when testing asynchronous execution flows that are + * initiated by synchronous calls. + * + * @returns A promise that resolves when all pending promises are completed. + */ +async function waitForAllPromises(): Promise { + // Wait for next tick to flush all pending promises. It's requires since + // we are testing some asynchronous execution flows that are started by + // synchronous calls. + await new Promise(process.nextTick); +} + describe('MultichainTransactionsController', () => { it('initialize with default state', () => { const { controller } = setupController({}); expect(controller.state).toStrictEqual({ nonEvmTransactions: {} }); }); - it('starts tracking when calling start', async () => { - const spyTracker = jest.spyOn( - MultichainTransactionsTracker.prototype, - 'start', - ); - const { controller } = setupController(); - controller.start(); - expect(spyTracker).toHaveBeenCalledTimes(1); - }); - - it('stops tracking when calling stop', async () => { - const spyTracker = jest.spyOn( - MultichainTransactionsTracker.prototype, - 'stop', - ); - const { controller } = setupController(); - controller.start(); - controller.stop(); - expect(spyTracker).toHaveBeenCalledTimes(1); - }); - - it('update transactions when calling updateTransactions', async () => { - const { controller } = setupController(); - - await controller.updateTransactions(); - - expect(controller.state).toStrictEqual({ - nonEvmTransactions: { - [mockBtcAccount.id]: { - transactions: mockTransactionResult.data, - next: null, - lastUpdated: expect.any(Number), - }, - }, - }); - }); - - it('update transactions when "AccountsController:accountAdded" is fired', async () => { + it('updates transactions when "AccountsController:accountAdded" is fired', async () => { const { controller, messenger, mockListMultichainAccounts } = setupController({ mocks: { @@ -195,10 +206,10 @@ describe('MultichainTransactionsController', () => { }, }); - controller.start(); mockListMultichainAccounts.mockReturnValue([mockBtcAccount]); messenger.publish('AccountsController:accountAdded', mockBtcAccount); - await controller.updateTransactions(); + + await waitForAllPromises(); expect(controller.state).toStrictEqual({ nonEvmTransactions: { @@ -211,12 +222,11 @@ describe('MultichainTransactionsController', () => { }); }); - it('update transactions when "AccountsController:accountRemoved" is fired', async () => { + it('updates transactions when "AccountsController:accountRemoved" is fired', async () => { const { controller, messenger, mockListMultichainAccounts } = setupController(); - controller.start(); - await controller.updateTransactions(); + await controller.updateTransactionsForAccount(mockBtcAccount.id); expect(controller.state).toStrictEqual({ nonEvmTransactions: { [mockBtcAccount.id]: { @@ -229,7 +239,6 @@ describe('MultichainTransactionsController', () => { messenger.publish('AccountsController:accountRemoved', mockBtcAccount.id); mockListMultichainAccounts.mockReturnValue([]); - await controller.updateTransactions(); expect(controller.state).toStrictEqual({ nonEvmTransactions: {}, @@ -244,17 +253,15 @@ describe('MultichainTransactionsController', () => { }, }); - controller.start(); mockListMultichainAccounts.mockReturnValue([mockEthAccount]); messenger.publish('AccountsController:accountAdded', mockEthAccount); - await controller.updateTransactions(); expect(controller.state).toStrictEqual({ nonEvmTransactions: {}, }); }); - it('should update transactions for a specific account', async () => { + it('updates transactions for a specific account', async () => { const { controller } = setupController(); await controller.updateTransactionsForAccount(mockBtcAccount.id); @@ -267,7 +274,63 @@ describe('MultichainTransactionsController', () => { }); }); - it('should handle pagination when fetching transactions', async () => { + it('filters out non-mainnet Solana transactions', async () => { + const mockSolTransaction = { + account: mockSolAccount.id, + type: 'send' as const, + status: 'confirmed' as const, + timestamp: Date.now(), + from: [], + to: [], + fees: [], + events: [ + { + status: 'confirmed' as const, + timestamp: Date.now(), + }, + ], + }; + const mockSolTransactions = { + data: [ + { + ...mockSolTransaction, + id: '3', + chain: MultichainNetwork.Solana, + }, + { + ...mockSolTransaction, + id: '1', + chain: MultichainNetwork.SolanaTestnet, + }, + { + ...mockSolTransaction, + id: '2', + chain: MultichainNetwork.SolanaDevnet, + }, + ], + next: null, + }; + // First transaction must be the mainnet one (for the test), so we assert this. + expect(mockSolTransactions.data[0].chain).toStrictEqual( + MultichainNetwork.Solana, + ); + + const { controller, mockSnapHandleRequest } = setupController({ + mocks: { + listMultichainAccounts: [mockSolAccount], + }, + }); + mockSnapHandleRequest.mockReturnValueOnce(mockSolTransactions); + + await controller.updateTransactionsForAccount(mockSolAccount.id); + + const { transactions } = + controller.state.nonEvmTransactions[mockSolAccount.id]; + expect(transactions).toHaveLength(1); + expect(transactions[0]).toStrictEqual(mockSolTransactions.data[0]); // First transaction is the mainnet one. + }); + + it('handles pagination when fetching transactions', async () => { const firstPage = { data: [ { @@ -330,11 +393,76 @@ describe('MultichainTransactionsController', () => { ); }); - it('should handle errors gracefully when updating transactions', async () => { - const { controller, mockSnapHandleRequest } = setupController(); - mockSnapHandleRequest.mockRejectedValue(new Error('Failed to fetch')); + it('handles errors gracefully when updating transactions', async () => { + const { controller, mockSnapHandleRequest, mockListMultichainAccounts } = + setupController({ + mocks: { + listMultichainAccounts: [], + }, + }); + + mockSnapHandleRequest.mockReset(); + mockSnapHandleRequest.mockImplementation(() => + Promise.reject(new Error('Failed to fetch')), + ); + mockListMultichainAccounts.mockReturnValue([mockBtcAccount]); + + await controller.updateTransactionsForAccount(mockBtcAccount.id); + await waitForAllPromises(); + + expect(controller.state.nonEvmTransactions).toStrictEqual({}); + }); + + it('handles errors gracefully when constructing the controller', async () => { + // This method will be used in the constructor of that controller. + const updateTransactionsForAccountSpy = jest.spyOn( + MultichainTransactionsController.prototype, + 'updateTransactionsForAccount', + ); + updateTransactionsForAccountSpy.mockRejectedValue( + new Error('Something unexpected happen'), + ); + + const { controller } = setupController({ + mocks: { + listMultichainAccounts: [mockBtcAccount], + }, + }); - await controller.updateTransactions(); expect(controller.state.nonEvmTransactions).toStrictEqual({}); }); + + it('updates transactions when receiving "AccountsController:accountTransactionsUpdated" event', async () => { + const { controller, messenger } = setupController({ + state: { + nonEvmTransactions: { + [mockBtcAccount.id]: { + transactions: [], + next: null, + lastUpdated: Date.now(), + }, + }, + }, + }); + const transactionUpdate = { + transactions: { + [mockBtcAccount.id]: mockTransactionResult.data, + }, + }; + + messenger.publish( + 'AccountsController:accountTransactionsUpdated', + transactionUpdate, + ); + + await waitForAllPromises(); + + expect( + controller.state.nonEvmTransactions[mockBtcAccount.id], + ).toStrictEqual({ + transactions: mockTransactionResult.data, + next: null, + lastUpdated: expect.any(Number), + }); + }); }); diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts index 683067bfaaf..74035f17119 100644 --- a/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts @@ -2,24 +2,33 @@ import type { AccountsControllerAccountAddedEvent, AccountsControllerAccountRemovedEvent, AccountsControllerListMultichainAccountsAction, + AccountsControllerAccountTransactionsUpdatedEvent, } from '@metamask/accounts-controller'; import { BaseController, type ControllerGetStateAction, type ControllerStateChangeEvent, - type RestrictedControllerMessenger, + type RestrictedMessenger, } from '@metamask/base-controller'; -import { isEvmAccountType, type Transaction } from '@metamask/keyring-api'; +import { + isEvmAccountType, + type Transaction, + type AccountTransactionsUpdatedEventPayload, +} from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { KeyringClient } from '@metamask/keyring-snap-client'; import type { HandleSnapRequest } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; import { HandlerType } from '@metamask/snaps-utils'; -import type { Json, JsonRpcRequest } from '@metamask/utils'; +import { + KnownCaipNamespace, + parseCaipChainId, + type Json, + type JsonRpcRequest, +} from '@metamask/utils'; import type { Draft } from 'immer'; -import { MultichainNetwork, TRANSACTIONS_CHECK_INTERVALS } from './constants'; -import { MultichainTransactionsTracker } from './MultichainTransactionsTracker'; +import { MultichainNetwork } from './constants'; const controllerName = 'MultichainTransactionsController'; @@ -64,14 +73,6 @@ export type MultichainTransactionsControllerGetStateAction = MultichainTransactionsControllerState >; -/** - * Updates the transactions of all supported accounts. - */ -export type MultichainTransactionsControllerListTransactionsAction = { - type: `${typeof controllerName}:updateTransactions`; - handler: MultichainTransactionsController['updateTransactions']; -}; - /** * Event emitted when the state of the {@link MultichainTransactionsController} changes. */ @@ -85,8 +86,7 @@ export type MultichainTransactionsControllerStateChange = * Actions exposed by the {@link MultichainTransactionsController}. */ export type MultichainTransactionsControllerActions = - | MultichainTransactionsControllerGetStateAction - | MultichainTransactionsControllerListTransactionsAction; + MultichainTransactionsControllerGetStateAction; /** * Events emitted by {@link MultichainTransactionsController}. @@ -97,14 +97,13 @@ export type MultichainTransactionsControllerEvents = /** * Messenger type for the MultichainTransactionsController. */ -export type MultichainTransactionsControllerMessenger = - RestrictedControllerMessenger< - typeof controllerName, - MultichainTransactionsControllerActions | AllowedActions, - MultichainTransactionsControllerEvents | AllowedEvents, - AllowedActions['type'], - AllowedEvents['type'] - >; +export type MultichainTransactionsControllerMessenger = RestrictedMessenger< + typeof controllerName, + MultichainTransactionsControllerActions | AllowedActions, + MultichainTransactionsControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; /** * Actions that this controller is allowed to call. @@ -118,7 +117,8 @@ export type AllowedActions = */ export type AllowedEvents = | AccountsControllerAccountAddedEvent - | AccountsControllerAccountRemovedEvent; + | AccountsControllerAccountRemovedEvent + | AccountsControllerAccountTransactionsUpdatedEvent; /** * {@link MultichainTransactionsController}'s metadata. @@ -152,8 +152,6 @@ export class MultichainTransactionsController extends BaseController< MultichainTransactionsControllerState, MultichainTransactionsControllerMessenger > { - readonly #tracker: MultichainTransactionsTracker; - constructor({ messenger, state, @@ -171,26 +169,29 @@ export class MultichainTransactionsController extends BaseController< }, }); - this.#tracker = new MultichainTransactionsTracker( - async (accountId: string, pagination: PaginationOptions) => - await this.#updateTransactions(accountId, pagination), - ); - - // Register all non-EVM accounts into the tracker + // Fetch initial transactions for all non-EVM accounts for (const account of this.#listAccounts()) { - if (this.#isNonEvmAccount(account)) { - this.#tracker.track(account.id, this.#getBlockTimeFor(account)); - } + this.updateTransactionsForAccount(account.id).catch((error) => { + console.error( + `Failed to fetch initial transactions for account ${account.id}:`, + error, + ); + }); } this.messagingSystem.subscribe( 'AccountsController:accountAdded', - (account) => this.#handleOnAccountAdded(account), + (account: InternalAccount) => this.#handleOnAccountAdded(account), ); this.messagingSystem.subscribe( 'AccountsController:accountRemoved', (accountId: string) => this.#handleOnAccountRemoved(accountId), ); + this.messagingSystem.subscribe( + 'AccountsController:accountTransactionsUpdated', + (transactionsUpdate: AccountTransactionsUpdatedEventPayload) => + this.#handleOnAccountTransactionsUpdated(transactionsUpdate), + ); } /** @@ -214,48 +215,6 @@ export class MultichainTransactionsController extends BaseController< return accounts.filter((account) => this.#isNonEvmAccount(account)); } - /** - * Updates the transactions for one account. - * - * @param accountId - The ID of the account to update transactions for. - * @param pagination - Options for paginating transaction results. - */ - async #updateTransactions(accountId: string, pagination: PaginationOptions) { - const account = this.#listAccounts().find( - (accountItem) => accountItem.id === accountId, - ); - - if (account?.metadata.snap) { - const response = await this.#getTransactions( - account.id, - account.metadata.snap.id, - pagination, - ); - - /** - * Filter only Solana transactions to ensure they're mainnet - * All other chain transactions are included as-is - */ - const transactions = response.data.filter((tx) => { - const chain = tx.chain as MultichainNetwork; - if (chain.startsWith(MultichainNetwork.Solana)) { - return chain === MultichainNetwork.Solana; - } - return true; - }); - - this.update((state: Draft) => { - const entry: TransactionStateEntry = { - transactions, - next: response.next, - lastUpdated: Date.now(), - }; - - Object.assign(state.nonEvmTransactions, { [account.id]: entry }); - }); - } - } - /** * Gets transactions for an account. * @@ -279,51 +238,55 @@ export class MultichainTransactionsController extends BaseController< } /** - * Updates transactions for a specific account + * Updates transactions for a specific account. This is used for the initial fetch + * when an account is first added. * * @param accountId - The ID of the account to get transactions for. */ async updateTransactionsForAccount(accountId: string) { - await this.#tracker.updateTransactionsForAccount(accountId); - } - - /** - * Updates the transactions of all supported accounts. This method doesn't return - * anything, but it updates the state of the controller. - */ - async updateTransactions() { - await this.#tracker.updateTransactions(); - } - - /** - * Starts the polling process. - */ - start(): void { - this.#tracker.start(); - } - - /** - * Stops the polling process. - */ - stop(): void { - this.#tracker.stop(); - } + try { + const account = this.#listAccounts().find( + (accountItem) => accountItem.id === accountId, + ); - /** - * Gets the block time for a given account. - * - * @param account - The account to get the block time for. - * @returns The block time for the account. - */ - #getBlockTimeFor(account: InternalAccount): number { - if (account.type in TRANSACTIONS_CHECK_INTERVALS) { - return TRANSACTIONS_CHECK_INTERVALS[ - account.type as keyof typeof TRANSACTIONS_CHECK_INTERVALS - ]; + if (account?.metadata.snap) { + const response = await this.#getTransactions( + account.id, + account.metadata.snap.id, + { limit: 10 }, + ); + + // Filter only Solana transactions to ensure they're on mainnet. + // All other chain transactions are included as-is. + // TODO: Maybe we should not do any filtering here? Or maybe have it + // being configurable somehow? + const transactions = response.data.filter((tx) => { + const chain = tx.chain as MultichainNetwork; + const { namespace } = parseCaipChainId(chain); + // Enum comparison is safe here as we control both enum values + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + if (namespace === KnownCaipNamespace.Solana) { + return chain === MultichainNetwork.Solana; + } + return true; + }); + + this.update((state: Draft) => { + const entry: TransactionStateEntry = { + transactions, + next: response.next, + lastUpdated: Date.now(), + }; + + Object.assign(state.nonEvmTransactions, { [account.id]: entry }); + }); + } + } catch (error) { + console.error( + `Failed to fetch transactions for account ${accountId}:`, + error, + ); } - throw new Error( - `Unsupported account type for transactions tracking: ${account.type}`, - ); } /** @@ -350,7 +313,7 @@ export class MultichainTransactionsController extends BaseController< return; } - this.#tracker.track(account.id, this.#getBlockTimeFor(account)); + await this.updateTransactionsForAccount(account.id); } /** @@ -359,10 +322,6 @@ export class MultichainTransactionsController extends BaseController< * @param accountId - The account ID being removed. */ async #handleOnAccountRemoved(accountId: string) { - if (this.#tracker.isTracked(accountId)) { - this.#tracker.untrack(accountId); - } - if (accountId in this.state.nonEvmTransactions) { this.update((state: Draft) => { delete state.nonEvmTransactions[accountId]; @@ -370,6 +329,26 @@ export class MultichainTransactionsController extends BaseController< } } + /** + * Handles transaction updates received from the AccountsController. + * + * @param transactionsUpdate - The transaction update event containing new transactions. + */ + #handleOnAccountTransactionsUpdated( + transactionsUpdate: AccountTransactionsUpdatedEventPayload, + ): void { + this.update((state: Draft) => { + Object.entries(transactionsUpdate.transactions).forEach( + ([accountId, transactions]) => { + if (accountId in state.nonEvmTransactions) { + state.nonEvmTransactions[accountId].transactions = transactions; + state.nonEvmTransactions[accountId].lastUpdated = Date.now(); + } + }, + ); + }); + } + /** * Gets a `KeyringClient` for a Snap. * diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsTracker.test.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsTracker.test.ts deleted file mode 100644 index d469e19add5..00000000000 --- a/packages/multichain-transactions-controller/src/MultichainTransactionsTracker.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { SolAccountType, SolMethod } from '@metamask/keyring-api'; -import { KeyringTypes } from '@metamask/keyring-controller'; -import { v4 as uuidv4 } from 'uuid'; - -import { MultichainTransactionsTracker } from './MultichainTransactionsTracker'; - -const mockStart = jest.fn(); -const mockStop = jest.fn(); - -jest.mock('./Poller', () => ({ - __esModule: true, - Poller: class { - readonly #callback: () => void; - - constructor(callback: () => void) { - this.#callback = callback; - } - - start = () => { - mockStart(); - this.#callback(); - }; - - stop = mockStop; - }, -})); - -const MOCK_TIMESTAMP = 1733788800; - -const mockSolanaAccount = { - address: '', - id: uuidv4(), - metadata: { - name: 'Solana Account', - importTime: Date.now(), - keyring: { - type: KeyringTypes.snap, - }, - snap: { - id: 'mock-solana-snap', - name: 'mock-solana-snap', - enabled: true, - }, - lastSelected: 0, - }, - options: {}, - methods: [SolMethod.SendAndConfirmTransaction], - type: SolAccountType.DataAccount, -}; - -/** - * Creates and returns a new MultichainTransactionsTracker instance with a mock update function. - * - * @returns The tracker instance and mock update function. - */ -function setupTracker(): { - tracker: MultichainTransactionsTracker; - mockUpdateTransactions: jest.Mock; -} { - const mockUpdateTransactions = jest.fn(); - const tracker = new MultichainTransactionsTracker(mockUpdateTransactions); - - return { - tracker, - mockUpdateTransactions, - }; -} - -describe('MultichainTransactionsTracker', () => { - it('starts polling when calling start', async () => { - const { tracker } = setupTracker(); - - tracker.start(); - expect(mockStart).toHaveBeenCalledTimes(1); - }); - - it('stops polling when calling stop', async () => { - const { tracker } = setupTracker(); - - tracker.start(); - tracker.stop(); - expect(mockStop).toHaveBeenCalledTimes(1); - }); - - it('is not tracking if none accounts have been registered', async () => { - const { tracker, mockUpdateTransactions } = setupTracker(); - - tracker.start(); - await tracker.updateTransactions(); - - expect(mockUpdateTransactions).not.toHaveBeenCalled(); - }); - - it('tracks account transactions', async () => { - const { tracker, mockUpdateTransactions } = setupTracker(); - - tracker.start(); - tracker.track(mockSolanaAccount.id, 0); - await tracker.updateTransactions(); - - expect(mockUpdateTransactions).toHaveBeenCalledWith(mockSolanaAccount.id, { - limit: 10, - }); - }); - - it('untracks account transactions', async () => { - const { tracker, mockUpdateTransactions } = setupTracker(); - - tracker.start(); - tracker.track(mockSolanaAccount.id, 0); - await tracker.updateTransactions(); - expect(mockUpdateTransactions).toHaveBeenCalledWith(mockSolanaAccount.id, { - limit: 10, - }); - - tracker.untrack(mockSolanaAccount.id); - await tracker.updateTransactions(); - expect(mockUpdateTransactions).toHaveBeenCalledTimes(1); - }); - - it('tracks account after being registered', async () => { - const { tracker } = setupTracker(); - - tracker.start(); - tracker.track(mockSolanaAccount.id, 0); - expect(tracker.isTracked(mockSolanaAccount.id)).toBe(true); - }); - - it('does not track account if not registered', async () => { - const { tracker } = setupTracker(); - - tracker.start(); - expect(tracker.isTracked(mockSolanaAccount.id)).toBe(false); - }); - - it('does not refresh transactions if they are considered up-to-date', async () => { - const { tracker, mockUpdateTransactions } = setupTracker(); - - const blockTime = 400; - jest - .spyOn(global.Date, 'now') - .mockImplementation(() => new Date(MOCK_TIMESTAMP).getTime()); - - tracker.start(); - tracker.track(mockSolanaAccount.id, blockTime); - await tracker.updateTransactions(); - expect(mockUpdateTransactions).toHaveBeenCalledTimes(1); - - await tracker.updateTransactions(); - expect(mockUpdateTransactions).toHaveBeenCalledTimes(1); - - jest - .spyOn(global.Date, 'now') - .mockImplementation(() => new Date(MOCK_TIMESTAMP + blockTime).getTime()); - - await tracker.updateTransactions(); - expect(mockUpdateTransactions).toHaveBeenCalledTimes(2); - }); - - it('calls updateTransactions when polling', async () => { - const { tracker } = setupTracker(); - const spyUpdateTransactions = jest.spyOn(tracker, 'updateTransactions'); - - tracker.start(); - jest.runOnlyPendingTimers(); - - expect(spyUpdateTransactions).toHaveBeenCalled(); - }); - - it('throws when asserting an untracked account', () => { - const { tracker } = setupTracker(); - const untrackerId = 'untracked-account'; - - expect(() => tracker.assertBeingTracked(untrackerId)).toThrow( - `Account is not being tracked: ${untrackerId}`, - ); - }); - - it('does not throw when asserting a tracked account', () => { - const { tracker } = setupTracker(); - const trackerId = 'tracked-account'; - - tracker.track(trackerId, 1000); - expect(() => tracker.assertBeingTracked(trackerId)).not.toThrow(); - }); -}); diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsTracker.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsTracker.ts deleted file mode 100644 index 29de3cb64f7..00000000000 --- a/packages/multichain-transactions-controller/src/MultichainTransactionsTracker.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { PaginationOptions } from './MultichainTransactionsController'; -import { Poller } from './Poller'; - -type TransactionInfo = { - lastUpdated: number; - blockTime: number; - pagination: PaginationOptions; -}; - -// Every 5s in milliseconds. -const TRANSACTIONS_TRACKING_INTERVAL = 5 * 1000; - -/** - * This class manages the tracking and periodic updating of transactions for multiple blockchain accounts. - * - * The tracker uses a polling mechanism to periodically check and update transactions - * for all tracked accounts, respecting each account's specific block time to determine - * when updates are needed. - */ -export class MultichainTransactionsTracker { - readonly #poller: Poller; - - readonly #updateTransactions: ( - accountId: string, - pagination: PaginationOptions, - ) => Promise; - - #transactions: Record = {}; - - constructor( - updateTransactionsCallback: ( - accountId: string, - pagination: PaginationOptions, - ) => Promise, - ) { - this.#updateTransactions = updateTransactionsCallback; - - this.#poller = new Poller(() => { - this.updateTransactions().catch((error) => { - console.error('Failed to update transactions:', error); - }); - }, TRANSACTIONS_TRACKING_INTERVAL); - } - - /** - * Starts the tracking process. - */ - start(): void { - this.#poller.start(); - } - - /** - * Stops the tracking process. - */ - stop(): void { - this.#poller.stop(); - } - - /** - * Checks if an account ID is being tracked. - * - * @param accountId - The account ID. - * @returns True if the account is being tracked, false otherwise. - */ - isTracked(accountId: string) { - return accountId in this.#transactions; - } - - /** - * Asserts that an account ID is being tracked. - * - * @param accountId - The account ID. - * @throws If the account ID is not being tracked. - */ - assertBeingTracked(accountId: string) { - if (!this.isTracked(accountId)) { - throw new Error(`Account is not being tracked: ${accountId}`); - } - } - - /** - * Starts tracking a new account ID. This method has no effect on already tracked - * accounts. - * - * @param accountId - The account ID. - * @param blockTime - The block time (used when refreshing the account transactions). - * @param pagination - Options for paginating transaction results. Defaults to { limit: 10 }. - */ - track( - accountId: string, - blockTime: number, - pagination: PaginationOptions = { limit: 10 }, - ) { - if (!this.isTracked(accountId)) { - this.#transactions[accountId] = { - lastUpdated: 0, - blockTime, - pagination, - }; - } - } - - /** - * Stops tracking a tracked account ID. - * - * @param accountId - The account ID. - * @throws If the account ID is not being tracked. - */ - untrack(accountId: string) { - this.assertBeingTracked(accountId); - delete this.#transactions[accountId]; - } - - /** - * Update the transactions for a tracked account ID. - * - * @param accountId - The account ID. - * @throws If the account ID is not being tracked. - */ - async updateTransactionsForAccount(accountId: string) { - this.assertBeingTracked(accountId); - - const info = this.#transactions[accountId]; - const isOutdated = Date.now() - info.lastUpdated >= info.blockTime; - const hasNoTransactionsYet = info.lastUpdated === 0; - - if (hasNoTransactionsYet || isOutdated) { - await this.#updateTransactions(accountId, info.pagination); - this.#transactions[accountId].lastUpdated = Date.now(); - } - } - - /** - * Update the transactions of all tracked accounts - */ - async updateTransactions() { - await Promise.allSettled( - Object.keys(this.#transactions).map(async (accountId) => { - await this.updateTransactionsForAccount(accountId); - }), - ); - } -} diff --git a/packages/multichain-transactions-controller/src/Poller.test.ts b/packages/multichain-transactions-controller/src/Poller.test.ts deleted file mode 100644 index ce82b7e5add..00000000000 --- a/packages/multichain-transactions-controller/src/Poller.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Poller } from './Poller'; - -describe('Poller', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('executes callback after starting', async () => { - const mockCallback = jest.fn(); - const poller = new Poller(mockCallback, 1000); - - poller.start(); - - expect(mockCallback).not.toHaveBeenCalled(); - jest.runOnlyPendingTimers(); - jest.advanceTimersByTime(0); - expect(mockCallback).toHaveBeenCalledTimes(1); - }); - - it('executes callback multiple times with interval', async () => { - const mockCallback = jest.fn(); - const poller = new Poller(mockCallback, 1000); - - poller.start(); - - jest.runOnlyPendingTimers(); - jest.advanceTimersByTime(0); - expect(mockCallback).toHaveBeenCalledTimes(1); - - jest.runOnlyPendingTimers(); - jest.advanceTimersByTime(0); - expect(mockCallback).toHaveBeenCalledTimes(2); - }); - - it('stops executing after stop is called', async () => { - const mockCallback = jest.fn(); - const poller = new Poller(mockCallback, 1000); - - poller.start(); - jest.runOnlyPendingTimers(); - jest.advanceTimersByTime(0); - expect(mockCallback).toHaveBeenCalledTimes(1); - - poller.stop(); - jest.runOnlyPendingTimers(); - jest.advanceTimersByTime(0); - expect(mockCallback).toHaveBeenCalledTimes(1); - }); - - it('handles async callbacks', async () => { - const mockCallback = jest.fn().mockImplementation(async () => { - await new Promise((resolve) => setTimeout(resolve, 500)); - }); - const poller = new Poller(mockCallback, 1000); - - poller.start(); - - jest.runOnlyPendingTimers(); - jest.advanceTimersByTime(500); // Advance time to complete the async operation - expect(mockCallback).toHaveBeenCalledTimes(1); - }); - it('does nothing when start is called multiple times', async () => { - const mockCallback = jest.fn(); - const poller = new Poller(mockCallback, 1000); - - poller.start(); - poller.start(); // Second call should do nothing - - jest.runOnlyPendingTimers(); - jest.advanceTimersByTime(0); - expect(mockCallback).toHaveBeenCalledTimes(1); - }); - - it('does nothing when stop is called before start', () => { - const mockCallback = jest.fn(); - const poller = new Poller(mockCallback, 1000); - - poller.stop(); - expect(mockCallback).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/multichain-transactions-controller/src/Poller.ts b/packages/multichain-transactions-controller/src/Poller.ts deleted file mode 100644 index 166014a5f3f..00000000000 --- a/packages/multichain-transactions-controller/src/Poller.ts +++ /dev/null @@ -1,28 +0,0 @@ -export class Poller { - readonly #interval: number; - - readonly #callback: () => void; - - #handle: NodeJS.Timeout | undefined = undefined; - - constructor(callback: () => void, interval: number) { - this.#interval = interval; - this.#callback = callback; - } - - start() { - if (this.#handle) { - return; - } - - this.#handle = setInterval(this.#callback, this.#interval); - } - - stop() { - if (!this.#handle) { - return; - } - clearInterval(this.#handle); - this.#handle = undefined; - } -} diff --git a/packages/multichain-transactions-controller/src/constants.ts b/packages/multichain-transactions-controller/src/constants.ts index 167331528f4..b273c68b3a7 100644 --- a/packages/multichain-transactions-controller/src/constants.ts +++ b/packages/multichain-transactions-controller/src/constants.ts @@ -1,5 +1,3 @@ -import { BtcAccountType, SolAccountType } from '@metamask/keyring-api'; - /** * The network identifiers for supported networks in CAIP-2 format. * Note: This is a temporary workaround until we have a more robust @@ -20,26 +18,3 @@ export enum MultichainNativeAsset { SolanaDevnet = `${MultichainNetwork.SolanaDevnet}/slip44:501`, SolanaTestnet = `${MultichainNetwork.SolanaTestnet}/slip44:501`, } - -const BITCOIN_AVG_BLOCK_TIME = 10 * 60 * 1000; // 10 minutes in milliseconds -const SOLANA_TRANSACTIONS_UPDATE_TIME = 7000; // 7 seconds -const BTC_TRANSACTIONS_UPDATE_TIME = BITCOIN_AVG_BLOCK_TIME / 2; - -export const TRANSACTIONS_CHECK_INTERVALS = { - // NOTE: We set an interval of half the average block time for bitcoin - // to mitigate when our interval is de-synchronized with the actual block time. - [BtcAccountType.P2wpkh]: BTC_TRANSACTIONS_UPDATE_TIME, - [SolAccountType.DataAccount]: SOLANA_TRANSACTIONS_UPDATE_TIME, -}; - -/** - * Maps network identifiers to their corresponding native asset types. - * Each network is mapped to an array containing its native asset for consistency. - */ -export const NETWORK_ASSETS_MAP: Record = { - [MultichainNetwork.Solana]: [MultichainNativeAsset.Solana], - [MultichainNetwork.SolanaTestnet]: [MultichainNativeAsset.SolanaTestnet], - [MultichainNetwork.SolanaDevnet]: [MultichainNativeAsset.SolanaDevnet], - [MultichainNetwork.Bitcoin]: [MultichainNativeAsset.Bitcoin], - [MultichainNetwork.BitcoinTestnet]: [MultichainNativeAsset.BitcoinTestnet], -}; diff --git a/packages/multichain-transactions-controller/src/index.ts b/packages/multichain-transactions-controller/src/index.ts index cc3b01064a5..1fa477c543e 100644 --- a/packages/multichain-transactions-controller/src/index.ts +++ b/packages/multichain-transactions-controller/src/index.ts @@ -4,9 +4,4 @@ export type { PaginationOptions, TransactionStateEntry, } from './MultichainTransactionsController'; -export { - TRANSACTIONS_CHECK_INTERVALS, - NETWORK_ASSETS_MAP, - MultichainNetwork, - MultichainNativeAsset, -} from './constants'; +export { MultichainNetwork, MultichainNativeAsset } from './constants'; diff --git a/packages/multichain/CHANGELOG.md b/packages/multichain/CHANGELOG.md index bc16f9ba8e1..dccb872fc42 100644 --- a/packages/multichain/CHANGELOG.md +++ b/packages/multichain/CHANGELOG.md @@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.1.1] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.4.5` to `^11.5.0` ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + +## [2.1.0] + +### Added + +- Add key Multichain API methods ([#4813](https://github.com/MetaMask/core/pull/4813)) + - Adds `getInternalScopesObject` and `getSessionScopes` helpers for transforming between `NormalizedScopesObject` and `InternalScopesObject`. + - Adds handlers for `wallet_getSession`, `wallet_invokeMethod`, and `wallet_revokeSession` methods. + - Adds `multichainMethodCallValidatorMiddleware` for validating Multichain API method params as defined in @metamask/api-specs. + - Adds `MultichainMiddlewareManager` to multiplex a request to other middleware based on requested scope. + - Adds `MultichainSubscriptionManager` to handle concurrent subscriptions across multiple scopes. + - Adds `bucketScopes` which groups the scopes in a `NormalizedScopesObject` based on if the scopes are already supported, could be supported, or are not supportable. + - Adds `getSupportedScopeObjects` helper for getting only the supported methods and notifications from each `NormalizedScopeObject` in a `NormalizedScopesObject`. + +### Changed + +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.4.5` ([#5012](https://github.com/MetaMask/core/pull/5135)) +- Bump `@metamask/permission-controller` from `^11.0.4` to `^11.0.5` ([#5012](https://github.com/MetaMask/core/pull/5135)) +- Bump `@metamask/utils` to `^11.0.1` and `@metamask/rpc-errors` to `^7.0.2` ([#5080](https://github.com/MetaMask/core/pull/5080)) + +### Fixed + +- Fixes `removeScope` mutator incorrectly returning malformed CAIP-25 caveat values ([#5183](https://github.com/MetaMask/core/pull/5183)). + ## [2.0.0] ### Added @@ -44,7 +74,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#4962](https://github.com/MetaMask/core/pull/4962)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain@2.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain@2.1.1...HEAD +[2.1.1]: https://github.com/MetaMask/core/compare/@metamask/multichain@2.1.0...@metamask/multichain@2.1.1 +[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/multichain@2.0.0...@metamask/multichain@2.1.0 [2.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain@1.1.2...@metamask/multichain@2.0.0 [1.1.2]: https://github.com/MetaMask/core/compare/@metamask/multichain@1.1.1...@metamask/multichain@1.1.2 [1.1.1]: https://github.com/MetaMask/core/compare/@metamask/multichain@1.1.0...@metamask/multichain@1.1.1 diff --git a/packages/multichain/package.json b/packages/multichain/package.json index 82ba5fb4a73..0264cb9ca40 100644 --- a/packages/multichain/package.json +++ b/packages/multichain/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/multichain", - "version": "2.0.0", + "version": "2.1.1", "description": "Provides types, helpers, adapters, and wrappers for facilitating CAIP Multichain sessions", "keywords": [ "MetaMask", @@ -48,20 +48,20 @@ }, "dependencies": { "@metamask/api-specs": "^0.10.12", - "@metamask/controller-utils": "^11.4.5", + "@metamask/controller-utils": "^11.5.0", "@metamask/eth-json-rpc-filters": "^9.0.0", "@metamask/rpc-errors": "^7.0.2", "@metamask/safe-event-emitter": "^3.0.0", - "@metamask/utils": "^11.0.1", + "@metamask/utils": "^11.1.0", "@open-rpc/schema-utils-js": "^2.0.5", "jsonschema": "^1.4.1", "lodash": "^4.17.21" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/json-rpc-engine": "^10.0.2", - "@metamask/network-controller": "^22.1.1", - "@metamask/permission-controller": "^11.0.5", + "@metamask/json-rpc-engine": "^10.0.3", + "@metamask/network-controller": "^22.2.1", + "@metamask/permission-controller": "^11.0.6", "@open-rpc/meta-schema": "^1.14.6", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", diff --git a/packages/multichain/src/caip25Permission.test.ts b/packages/multichain/src/caip25Permission.test.ts index b4caba7456f..4ae70e7c018 100644 --- a/packages/multichain/src/caip25Permission.test.ts +++ b/packages/multichain/src/caip25Permission.test.ts @@ -89,6 +89,8 @@ describe('caip25EndowmentBuilder', () => { accounts: [], }, }, + sessionProperties: {}, + isMultichainOrigin: true, }, }); }); @@ -118,6 +120,8 @@ describe('caip25EndowmentBuilder', () => { }, }, optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: true, }, }); }); @@ -150,6 +154,8 @@ describe('caip25EndowmentBuilder', () => { }, }, optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: true, }, }); }); @@ -228,6 +234,7 @@ describe('caip25EndowmentBuilder', () => { }, }, optionalScopes: {}, + sessionProperties: {}, isMultichainOrigin: true, }; const result = removeAccount(caveatValue, '0x1'); @@ -240,6 +247,7 @@ describe('caip25EndowmentBuilder', () => { }, }, optionalScopes: {}, + sessionProperties: {}, isMultichainOrigin: true, }, }); @@ -253,6 +261,7 @@ describe('caip25EndowmentBuilder', () => { accounts: ['eip155:1:0x1', 'eip155:1:0x2'], }, }, + sessionProperties: {}, isMultichainOrigin: true, }; const result = removeAccount(caveatValue, '0x1'); @@ -265,6 +274,7 @@ describe('caip25EndowmentBuilder', () => { accounts: ['eip155:1:0x2'], }, }, + sessionProperties: {}, isMultichainOrigin: true, }, }); @@ -285,6 +295,7 @@ describe('caip25EndowmentBuilder', () => { accounts: ['eip155:3:0x1', 'eip155:3:0x2'], }, }, + sessionProperties: {}, isMultichainOrigin: true, }; const result = removeAccount(caveatValue, '0x1'); @@ -304,6 +315,7 @@ describe('caip25EndowmentBuilder', () => { accounts: ['eip155:3:0x2'], }, }, + sessionProperties: {}, isMultichainOrigin: true, }, }); diff --git a/packages/multichain/src/caip25Permission.ts b/packages/multichain/src/caip25Permission.ts index a865aefeb86..9a417f3c707 100644 --- a/packages/multichain/src/caip25Permission.ts +++ b/packages/multichain/src/caip25Permission.ts @@ -324,6 +324,7 @@ function removeScope( } const updatedCaveatValue = { + ...caip25CaveatValue, requiredScopes: Object.fromEntries(newRequiredScopes), optionalScopes: Object.fromEntries(newOptionalScopes), }; diff --git a/packages/multichain/src/handlers/wallet-getSession.ts b/packages/multichain/src/handlers/wallet-getSession.ts index bf343495091..5e04bf9ed03 100644 --- a/packages/multichain/src/handlers/wallet-getSession.ts +++ b/packages/multichain/src/handlers/wallet-getSession.ts @@ -1,6 +1,5 @@ import type { Caveat } from '@metamask/permission-controller'; import type { JsonRpcRequest, JsonRpcSuccess } from '@metamask/utils'; -import type { NormalizedScopesObject } from 'src/scope/types'; import { getSessionScopes } from '../adapters/caip-permission-adapter-session-scopes'; import type { Caip25CaveatValue } from '../caip25Permission'; @@ -8,6 +7,7 @@ import { Caip25CaveatType, Caip25EndowmentPermissionName, } from '../caip25Permission'; +import type { NormalizedScopesObject } from '../scope/types'; /** * Handler for the `wallet_getSession` RPC method as specified by [CAIP-312](https://chainagnostic.org/CAIPs/caip-312). diff --git a/packages/name-controller/CHANGELOG.md b/packages/name-controller/CHANGELOG.md index cc5fe4e49c5..9262cc84c79 100644 --- a/packages/name-controller/CHANGELOG.md +++ b/packages/name-controller/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [8.0.3] + ### Changed +- Bump `@metamask/base-controller` from `^7.1.0` to `^8.0.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^10.0.0` to `^11.1.0` ([#5080](https://github.com/MetaMask/core/pull/5080)), ([#5223](https://github.com/MetaMask/core/pull/5223)) - Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) ## [8.0.2] @@ -157,7 +162,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial Release ([#1647](https://github.com/MetaMask/core/pull/1647)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/name-controller@8.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/name-controller@8.0.3...HEAD +[8.0.3]: https://github.com/MetaMask/core/compare/@metamask/name-controller@8.0.2...@metamask/name-controller@8.0.3 [8.0.2]: https://github.com/MetaMask/core/compare/@metamask/name-controller@8.0.1...@metamask/name-controller@8.0.2 [8.0.1]: https://github.com/MetaMask/core/compare/@metamask/name-controller@8.0.0...@metamask/name-controller@8.0.1 [8.0.0]: https://github.com/MetaMask/core/compare/@metamask/name-controller@7.0.0...@metamask/name-controller@8.0.0 diff --git a/packages/name-controller/package.json b/packages/name-controller/package.json index b724d0c73b2..698f6217525 100644 --- a/packages/name-controller/package.json +++ b/packages/name-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/name-controller", - "version": "8.0.2", + "version": "8.0.3", "description": "Stores and suggests names for values such as Ethereum addresses", "keywords": [ "MetaMask", @@ -48,9 +48,9 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/controller-utils": "^11.4.5", - "@metamask/utils": "^11.0.1", + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", + "@metamask/utils": "^11.1.0", "async-mutex": "^0.5.0" }, "devDependencies": { diff --git a/packages/name-controller/src/NameController.ts b/packages/name-controller/src/NameController.ts index eb015d3465d..3b2a0922252 100644 --- a/packages/name-controller/src/NameController.ts +++ b/packages/name-controller/src/NameController.ts @@ -1,7 +1,7 @@ import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import { isSafeDynamicKey } from '@metamask/controller-utils'; @@ -89,7 +89,7 @@ export type NameControllerActions = GetNameState; export type NameControllerEvents = NameStateChange; -export type NameControllerMessenger = RestrictedControllerMessenger< +export type NameControllerMessenger = RestrictedMessenger< typeof controllerName, NameControllerActions, NameControllerEvents, @@ -141,7 +141,7 @@ export class NameController extends BaseController< * Construct a Name controller. * * @param options - Controller options. - * @param options.messenger - Restricted controller messenger for the name controller. + * @param options.messenger - Restricted messenger for the name controller. * @param options.providers - Array of name provider instances to propose names. * @param options.state - Initial state to set on the controller. * @param options.updateDelay - The delay in seconds before a new request to a source should be made. @@ -398,7 +398,9 @@ export class NameController extends BaseController< ): NameProviderSourceResult | undefined { const error = result?.error ?? responseError ?? undefined; const updateDelay = result?.updateDelay ?? undefined; - let proposedNames = error ? undefined : result?.proposedNames ?? undefined; + let proposedNames = error + ? undefined + : (result?.proposedNames ?? undefined); if (proposedNames) { proposedNames = proposedNames.filter( diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index e9c30aab0df..77dc401045b 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -7,9 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Implement circuit breaker pattern when retrying requests to Infura and custom RPC endpoints ([#5290](https://github.com/MetaMask/core/pull/5290)) + - If the network is perceived to be down after 5 attempts, further retries will be paused for 30 seconds + - "Down" means the following: + - A failure to reach the network (exact error depending on platform / HTTP client) + - The request responds with a non-JSON-parseable or non-JSON-RPC-compatible body + - The request returns a non-200 response +- Use exponential backoff / jitter when retrying requests to Infura and custom RPC endpoints ([#5290](https://github.com/MetaMask/core/pull/5290)) + - As requests are retried, the delay between retries will increase exponentially (using random variance to prevent bursts) + ### Changed -- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) +- **BREAKING:** `NetworkController` constructor now takes two required options, `fetch` and `btoa` ([#5290](https://github.com/MetaMask/core/pull/5290)) + - These are passed along to functions that create the JSON-RPC middleware +- Synchronize retry logic and error handling behavior between Infura and custom RPC endpoints ([#5290](https://github.com/MetaMask/core/pull/5290)) + - A request to a custom endpoint that returns a 418 response will no longer return a JSON-RPC response with the error "Request is being rate limited" + - A request to a custom endpoint that returns a 429 response now returns a JSON-RPC response with the error "Request is being rate limited" + - A request to a custom endpoint that throws an "ECONNRESET" error will now be retried up to 5 times + - A request to a Infura endpoint that fails more than 5 times in a row will now respond with a JSON-RPC error that encompasses the failure instead of hiding it as "InfuraProvider - cannot complete request. All retries exhausted" + - A request to a Infura endpoint that returns a non-retriable, non-2xx response will now respond with a JSON-RPC error that has the underling message "Non-200 status code: '\'" rather than including the raw response from the endpoint + - A request to a custom endpoint that fails with a retriable error more than 5 times in a row will now respond with a JSON-RPC error that encompasses the failure instead of returning an empty response + - A "retriable error" is now regarded as the following: + - A failure to reach the network (exact error depending on platform / HTTP client) + - The request responds with a non-JSON-parseable or non-JSON-RPC-compatible body + - The request returns a 503 or 504 response +- Bump dependencies to support usage of RPC services internally for network requests ([#5290](https://github.com/MetaMask/core/pull/5290)) + - Bump `@metamask/eth-json-rpc-infura` to `^10.1.0` + - Bump `@metamask/eth-json-rpc-middleware` to `^15.1.0` + +## [22.2.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) + +## [22.2.0] + +### Added + +- Export `AbstractRpcService` type ([#5263](https://github.com/MetaMask/core/pull/5263)) + +### Changed + +- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.1` ([#5079](https://github.com/MetaMask/core/pull/5079), [#5135](https://github.com/MetaMask/core/pull/5135)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0` ([#5135](https://github.com/MetaMask/core/pull/5135), [#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/eth-json-rpc-provider` from `^4.1.6` to `^4.1.8` ([#5082](https://github.com/MetaMask/core/pull/5082), [#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/json-rpc-engine` from `^10.0.1` to `^10.0.3` ([#5082](https://github.com/MetaMask/core/pull/5082), [#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/rpc-errors` from `^7.0.1` to `^7.0.2` ([#5080](https://github.com/MetaMask/core/pull/5080)) +- Bump `@metamask/utils` from `^10.0.0` to `^11.1.0` ([#5080](https://github.com/MetaMask/core/pull/5080), [#5223](https://github.com/MetaMask/core/pull/5223)) + +### Fixed + +- Fix `lookupNetwork` so that it will no longer throw an error if `networkDidChange` subscriptions have been removed before it returns ([#5116](https://github.com/MetaMask/core/pull/5116)) + - This error could occur if the NetworkController's messenger is cleared of subscriptions, as in a "destroy" step. +- Fix race condition so that after adding a new RPC endpoint to a network, it is possible to access the new endpoint inside of a `stateChange` event listener via `getNetworkConfigurationByNetworkClientId` ([#5122](https://github.com/MetaMask/core/pull/5122)) +- Fix `selectAvailableNetworkClientIds` so that it is properly memoized ([#5193](https://github.com/MetaMask/core/pull/5193)) ## [22.1.1] @@ -698,7 +752,9 @@ 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@22.1.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/network-controller@22.2.1...HEAD +[22.2.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@22.2.0...@metamask/network-controller@22.2.1 +[22.2.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@22.1.1...@metamask/network-controller@22.2.0 [22.1.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@22.1.0...@metamask/network-controller@22.1.1 [22.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@22.0.2...@metamask/network-controller@22.1.0 [22.0.2]: https://github.com/MetaMask/core/compare/@metamask/network-controller@22.0.1...@metamask/network-controller@22.0.2 diff --git a/packages/network-controller/package.json b/packages/network-controller/package.json index 295c9f493ab..c80f31db7f8 100644 --- a/packages/network-controller/package.json +++ b/packages/network-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/network-controller", - "version": "22.1.1", + "version": "22.2.1", "description": "Provides an interface to the currently selected network via a MetaMask-compatible provider object", "keywords": [ "MetaMask", @@ -47,17 +47,17 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/controller-utils": "^11.4.5", + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", "@metamask/eth-block-tracker": "^11.0.3", - "@metamask/eth-json-rpc-infura": "^10.0.0", - "@metamask/eth-json-rpc-middleware": "^15.0.1", - "@metamask/eth-json-rpc-provider": "^4.1.7", + "@metamask/eth-json-rpc-infura": "^10.1.0", + "@metamask/eth-json-rpc-middleware": "^15.1.0", + "@metamask/eth-json-rpc-provider": "^4.1.8", "@metamask/eth-query": "^4.0.0", - "@metamask/json-rpc-engine": "^10.0.2", + "@metamask/json-rpc-engine": "^10.0.3", "@metamask/rpc-errors": "^7.0.2", "@metamask/swappable-obj-proxy": "^2.3.0", - "@metamask/utils": "^11.0.1", + "@metamask/utils": "^11.1.0", "async-mutex": "^0.5.0", "fast-deep-equal": "^3.1.3", "immer": "^9.0.6", @@ -72,11 +72,13 @@ "@types/jest": "^27.4.1", "@types/jest-when": "^2.7.3", "@types/lodash": "^4.14.191", + "@types/node-fetch": "^2.6.12", "deepmerge": "^4.2.2", "jest": "^27.5.1", "jest-when": "^3.4.2", "lodash": "^4.17.21", "nock": "^13.3.1", + "node-fetch": "^2.7.0", "sinon": "^9.2.4", "ts-jest": "^27.1.4", "typedoc": "^0.24.8", diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index aeda162aa55..73ac9d96631 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -1,7 +1,7 @@ import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { Partialize } from '@metamask/controller-utils'; @@ -518,7 +518,7 @@ export type NetworkControllerActions = | NetworkControllerRemoveNetworkAction | NetworkControllerUpdateNetworkAction; -export type NetworkControllerMessenger = RestrictedControllerMessenger< +export type NetworkControllerMessenger = RestrictedMessenger< typeof controllerName, NetworkControllerActions, NetworkControllerEvents, @@ -531,6 +531,15 @@ export type NetworkControllerOptions = { infuraProjectId: string; state?: Partial; log?: Logger; + /** + * A function that can be used to make an HTTP request, compatible with the + * Fetch API. + */ + fetch: typeof fetch; + /** + * A function that can be used to convert a binary string into base-64. + */ + btoa: typeof btoa; }; /** @@ -589,6 +598,16 @@ export function getDefaultNetworkControllerState(): NetworkState { }; } +/** + * Redux selector for getting all network configurations from NetworkController + * state, keyed by chain ID. + * + * @param state - NetworkController state + * @returns All registered network configurations, keyed by chain ID. + */ +const selectNetworkConfigurationsByChainId = (state: NetworkState) => + state.networkConfigurationsByChainId; + /** * Get a list of all network configurations. * @@ -602,8 +621,22 @@ export function getNetworkConfigurations( } /** - * Get a list of all available client IDs from a list of - * network configurations + * Redux selector for getting a list of all network configurations from + * NetworkController state. + * + * @param state - NetworkController state + * @returns A list of all available network configurations + */ +export const selectNetworkConfigurations = createSelector( + selectNetworkConfigurationsByChainId, + (networkConfigurationsByChainId) => + Object.values(networkConfigurationsByChainId), +); + +/** + * Get a list of all available network client IDs from a list of network + * configurations. + * * @param networkConfigurations - The array of network configurations * @returns A list of all available client IDs */ @@ -617,8 +650,15 @@ export function getAvailableNetworkClientIds( ); } +/** + * Redux selector for getting a list of all available network client IDs + * from NetworkController state. + * + * @param state - NetworkController state + * @returns A list of all available network client IDs. + */ export const selectAvailableNetworkClientIds = createSelector( - [getNetworkConfigurations], + selectNetworkConfigurations, getAvailableNetworkClientIds, ); @@ -773,7 +813,9 @@ function validateNetworkControllerState(state: NetworkState) { const networkConfigurationEntries = Object.entries( state.networkConfigurationsByChainId, ); - const networkClientIds = selectAvailableNetworkClientIds(state); + const networkClientIds = getAvailableNetworkClientIds( + getNetworkConfigurations(state), + ); if (networkConfigurationEntries.length === 0) { throw new Error( @@ -876,6 +918,10 @@ export class NetworkController extends BaseController< #log: Logger | undefined; + readonly #fetch: typeof fetch; + + readonly #btoa: typeof btoa; + #networkConfigurationsByNetworkClientId: Map< NetworkClientId, NetworkConfiguration @@ -886,6 +932,8 @@ export class NetworkController extends BaseController< state, infuraProjectId, log, + fetch: givenFetch, + btoa: givenBtoa, }: NetworkControllerOptions) { const initialState = { ...getDefaultNetworkControllerState(), ...state }; validateNetworkControllerState(initialState); @@ -915,6 +963,8 @@ export class NetworkController extends BaseController< this.#infuraProjectId = infuraProjectId; this.#log = log; + this.#fetch = givenFetch; + this.#btoa = givenBtoa; this.#previouslySelectedNetworkClientId = this.state.selectedNetworkClientId; @@ -1301,10 +1351,30 @@ export class NetworkController extends BaseController< let networkChanged = false; const listener = () => { networkChanged = true; - this.messagingSystem.unsubscribe( - 'NetworkController:networkDidChange', - listener, - ); + try { + this.messagingSystem.unsubscribe( + 'NetworkController:networkDidChange', + listener, + ); + } catch (error) { + // In theory, this `catch` should not be necessary given that this error + // would occur "inside" of the call to `#determineEIP1559Compatibility` + // below and so it should be caught by the `try`/`catch` below (it is + // impossible to reproduce in tests for that reason). However, somehow + // it occurs within Mobile and so we have to add our own `try`/`catch` + // here. + /* istanbul ignore next */ + if ( + !(error instanceof Error) || + error.message !== + 'Subscription not found for event: NetworkController:networkDidChange' + ) { + // Again, this error should not happen and is impossible to reproduce + // in tests. + /* istanbul ignore next */ + throw error; + } + } }; this.messagingSystem.subscribe( 'NetworkController:networkDidChange', @@ -1371,10 +1441,21 @@ export class NetworkController extends BaseController< // in the process of being called, so we don't need to go further. return; } - this.messagingSystem.unsubscribe( - 'NetworkController:networkDidChange', - listener, - ); + + try { + this.messagingSystem.unsubscribe( + 'NetworkController:networkDidChange', + listener, + ); + } catch (error) { + if ( + !(error instanceof Error) || + error.message !== + 'Subscription not found for event: NetworkController:networkDidChange' + ) { + throw error; + } + } this.update((state) => { const meta = state.networksMetadata[state.selectedNetworkClientId]; @@ -2361,20 +2442,28 @@ export class NetworkController extends BaseController< autoManagedNetworkClientRegistry[NetworkClientType.Infura][ addedRpcEndpoint.networkClientId ] = createAutoManagedNetworkClient({ - type: NetworkClientType.Infura, - chainId: networkFields.chainId, - network: addedRpcEndpoint.networkClientId, - infuraProjectId: this.#infuraProjectId, - ticker: networkFields.nativeCurrency, + networkClientConfiguration: { + type: NetworkClientType.Infura, + chainId: networkFields.chainId, + network: addedRpcEndpoint.networkClientId, + infuraProjectId: this.#infuraProjectId, + ticker: networkFields.nativeCurrency, + }, + fetch: this.#fetch, + btoa: this.#btoa, }); } else { autoManagedNetworkClientRegistry[NetworkClientType.Custom][ addedRpcEndpoint.networkClientId ] = createAutoManagedNetworkClient({ - type: NetworkClientType.Custom, - chainId: networkFields.chainId, - rpcUrl: addedRpcEndpoint.url, - ticker: networkFields.nativeCurrency, + networkClientConfiguration: { + type: NetworkClientType.Custom, + chainId: networkFields.chainId, + rpcUrl: addedRpcEndpoint.url, + ticker: networkFields.nativeCurrency, + }, + fetch: this.#fetch, + btoa: this.#btoa, }); } } @@ -2525,21 +2614,29 @@ export class NetworkController extends BaseController< return [ rpcEndpoint.networkClientId, createAutoManagedNetworkClient({ - type: NetworkClientType.Infura, - network: infuraNetworkName, - infuraProjectId: this.#infuraProjectId, - chainId: networkConfiguration.chainId, - ticker: networkConfiguration.nativeCurrency, + networkClientConfiguration: { + type: NetworkClientType.Infura, + network: infuraNetworkName, + infuraProjectId: this.#infuraProjectId, + chainId: networkConfiguration.chainId, + ticker: networkConfiguration.nativeCurrency, + }, + fetch: this.#fetch, + btoa: this.#btoa, }), ] as const; } return [ rpcEndpoint.networkClientId, createAutoManagedNetworkClient({ - type: NetworkClientType.Custom, - chainId: networkConfiguration.chainId, - rpcUrl: rpcEndpoint.url, - ticker: networkConfiguration.nativeCurrency, + networkClientConfiguration: { + type: NetworkClientType.Custom, + chainId: networkConfiguration.chainId, + rpcUrl: rpcEndpoint.url, + ticker: networkConfiguration.nativeCurrency, + }, + fetch: this.#fetch, + btoa: this.#btoa, }), ] as const; }); 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 421c720ff5c..52eb5339255 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 @@ -1,6 +1,5 @@ import { BUILT_IN_NETWORKS, NetworkType } from '@metamask/controller-utils'; -import { mockNetwork } from '../../../tests/mock-network'; import { createAutoManagedNetworkClient } from './create-auto-managed-network-client'; import * as createNetworkClientModule from './create-network-client'; import type { @@ -8,6 +7,7 @@ import type { InfuraNetworkClientConfiguration, } from './types'; import { NetworkClientType } from './types'; +import { mockNetwork } from '../../../tests/mock-network'; describe('createAutoManagedNetworkClient', () => { const networkClientConfigurations: [ @@ -31,9 +31,11 @@ describe('createAutoManagedNetworkClient', () => { for (const networkClientConfiguration of networkClientConfigurations) { describe(`given configuration for a ${networkClientConfiguration.type} network client`, () => { it('allows the network client configuration to be accessed', () => { - const { configuration } = createAutoManagedNetworkClient( + const { configuration } = createAutoManagedNetworkClient({ networkClientConfiguration, - ); + fetch, + btoa, + }); expect(configuration).toStrictEqual(networkClientConfiguration); }); @@ -41,14 +43,20 @@ describe('createAutoManagedNetworkClient', () => { it('does not make any network requests initially', () => { // If unexpected requests occurred, then Nock would throw expect(() => { - createAutoManagedNetworkClient(networkClientConfiguration); + createAutoManagedNetworkClient({ + networkClientConfiguration, + fetch, + btoa, + }); }).not.toThrow(); }); it('returns a provider proxy that has the same interface as a provider', () => { - const { provider } = createAutoManagedNetworkClient( + const { provider } = createAutoManagedNetworkClient({ networkClientConfiguration, - ); + fetch, + btoa, + }); // This also tests the `has` trap in the proxy expect('addListener' in provider).toBe(true); @@ -87,9 +95,11 @@ describe('createAutoManagedNetworkClient', () => { ], }); - const { provider } = createAutoManagedNetworkClient( + const { provider } = createAutoManagedNetworkClient({ networkClientConfiguration, - ); + fetch, + btoa, + }); const result = await provider.request({ id: 1, @@ -121,9 +131,11 @@ describe('createAutoManagedNetworkClient', () => { 'createNetworkClient', ); - const { provider } = createAutoManagedNetworkClient( + const { provider } = createAutoManagedNetworkClient({ networkClientConfiguration, - ); + fetch, + btoa, + }); await provider.request({ id: 1, @@ -138,15 +150,19 @@ describe('createAutoManagedNetworkClient', () => { params: [], }); expect(createNetworkClientMock).toHaveBeenCalledTimes(1); - expect(createNetworkClientMock).toHaveBeenCalledWith( - networkClientConfiguration, - ); + expect(createNetworkClientMock).toHaveBeenCalledWith({ + configuration: networkClientConfiguration, + fetch, + btoa, + }); }); it('returns a block tracker proxy that has the same interface as a block tracker', () => { - const { blockTracker } = createAutoManagedNetworkClient( + const { blockTracker } = createAutoManagedNetworkClient({ networkClientConfiguration, - ); + fetch, + btoa, + }); // This also tests the `has` trap in the proxy expect('addListener' in blockTracker).toBe(true); @@ -196,9 +212,11 @@ describe('createAutoManagedNetworkClient', () => { ], }); - const { blockTracker } = createAutoManagedNetworkClient( + const { blockTracker } = createAutoManagedNetworkClient({ networkClientConfiguration, - ); + fetch, + btoa, + }); const blockNumberViaLatest = await new Promise((resolve) => { blockTracker.once('latest', resolve); @@ -251,9 +269,11 @@ describe('createAutoManagedNetworkClient', () => { 'createNetworkClient', ); - const { blockTracker } = createAutoManagedNetworkClient( + const { blockTracker } = createAutoManagedNetworkClient({ networkClientConfiguration, - ); + fetch, + btoa, + }); await new Promise((resolve) => { blockTracker.once('latest', resolve); @@ -264,9 +284,11 @@ describe('createAutoManagedNetworkClient', () => { await blockTracker.getLatestBlock(); await blockTracker.checkForLatestBlock(); expect(createNetworkClientMock).toHaveBeenCalledTimes(1); - expect(createNetworkClientMock).toHaveBeenCalledWith( - networkClientConfiguration, - ); + expect(createNetworkClientMock).toHaveBeenCalledWith({ + configuration: networkClientConfiguration, + fetch, + btoa, + }); }); it('allows the block tracker to be destroyed', () => { @@ -284,9 +306,11 @@ describe('createAutoManagedNetworkClient', () => { }, ], }); - const { blockTracker, destroy } = createAutoManagedNetworkClient( + const { blockTracker, destroy } = createAutoManagedNetworkClient({ networkClientConfiguration, - ); + fetch, + btoa, + }); // Start the block tracker blockTracker.on('latest', () => { // do nothing 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 543c6582815..d310c561d99 100644 --- a/packages/network-controller/src/create-auto-managed-network-client.ts +++ b/packages/network-controller/src/create-auto-managed-network-client.ts @@ -59,15 +59,26 @@ const UNINITIALIZED_TARGET = { __UNINITIALIZED__: true }; * part of the network client is serving as the receiver. The network client is * then cached for subsequent usages. * - * @param networkClientConfiguration - The configuration object that will be + * @param args - The arguments. + * @param args.networkClientConfiguration - The configuration object that will be * used to instantiate the network client when it is needed. + * @param args.fetch - A function that can be used to make an HTTP request, + * compatible with the Fetch API. + * @param args.btoa - A function that can be used to convert a binary string + * into base-64. * @returns The auto-managed network client. */ export function createAutoManagedNetworkClient< Configuration extends NetworkClientConfiguration, ->( - networkClientConfiguration: Configuration, -): AutoManagedNetworkClient { +>({ + networkClientConfiguration, + fetch: givenFetch, + btoa: givenBtoa, +}: { + networkClientConfiguration: Configuration; + fetch: typeof fetch; + btoa: typeof btoa; +}): AutoManagedNetworkClient { let networkClient: NetworkClient | undefined; const providerProxy = new Proxy(UNINITIALIZED_TARGET, { @@ -78,7 +89,11 @@ export function createAutoManagedNetworkClient< return networkClient?.provider; } - networkClient ??= createNetworkClient(networkClientConfiguration); + networkClient ??= createNetworkClient({ + configuration: networkClientConfiguration, + fetch: givenFetch, + btoa: givenBtoa, + }); if (networkClient === undefined) { throw new Error( "It looks like `createNetworkClient` didn't return anything. Perhaps it's being mocked?", @@ -115,7 +130,11 @@ export function createAutoManagedNetworkClient< if (propertyName === REFLECTIVE_PROPERTY_NAME) { return true; } - networkClient ??= createNetworkClient(networkClientConfiguration); + networkClient ??= createNetworkClient({ + configuration: networkClientConfiguration, + fetch: givenFetch, + btoa: givenBtoa, + }); const { provider } = networkClient; return propertyName in provider; }, @@ -131,7 +150,11 @@ export function createAutoManagedNetworkClient< return networkClient?.blockTracker; } - networkClient ??= createNetworkClient(networkClientConfiguration); + networkClient ??= createNetworkClient({ + configuration: networkClientConfiguration, + fetch: givenFetch, + btoa: givenBtoa, + }); if (networkClient === undefined) { throw new Error( "It looks like createNetworkClient returned undefined. Perhaps it's mocked?", @@ -168,7 +191,11 @@ export function createAutoManagedNetworkClient< if (propertyName === REFLECTIVE_PROPERTY_NAME) { return true; } - networkClient ??= createNetworkClient(networkClientConfiguration); + networkClient ??= createNetworkClient({ + configuration: networkClientConfiguration, + fetch: givenFetch, + btoa: givenBtoa, + }); const { blockTracker } = networkClient; return propertyName in blockTracker; }, diff --git a/packages/network-controller/src/create-network-client.ts b/packages/network-controller/src/create-network-client.ts index e6620184878..2944dceb496 100644 --- a/packages/network-controller/src/create-network-client.ts +++ b/packages/network-controller/src/create-network-client.ts @@ -25,6 +25,7 @@ import { import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; import type { Hex, Json, JsonRpcParams } from '@metamask/utils'; +import { RpcService } from './rpc-service/rpc-service'; import type { BlockTracker, NetworkClientConfiguration, @@ -48,30 +49,50 @@ export type NetworkClient = { /** * Create a JSON RPC network client for a specific network. * - * @param networkConfig - The network configuration. + * @param args - The arguments. + * @param args.configuration - The network configuration. + * @param args.fetch - A function that can be used to make an HTTP request, + * compatible with the Fetch API. + * @param args.btoa - A function that can be used to convert a binary string + * into base-64. * @returns The network client. */ -export function createNetworkClient( - networkConfig: NetworkClientConfiguration, -): NetworkClient { +export function createNetworkClient({ + configuration, + fetch: givenFetch, + btoa: givenBtoa, +}: { + configuration: NetworkClientConfiguration; + fetch: typeof fetch; + btoa: typeof btoa; +}): NetworkClient { + const rpcService = + configuration.type === NetworkClientType.Infura + ? new RpcService({ + fetch: givenFetch, + btoa: givenBtoa, + endpointUrl: `https://${configuration.network}.infura.io/v3/${configuration.infuraProjectId}`, + }) + : new RpcService({ + fetch: givenFetch, + btoa: givenBtoa, + endpointUrl: configuration.rpcUrl, + }); + const rpcApiMiddleware = - networkConfig.type === NetworkClientType.Infura + configuration.type === NetworkClientType.Infura ? createInfuraMiddleware({ - network: networkConfig.network, - projectId: networkConfig.infuraProjectId, - maxAttempts: 5, - source: 'metamask', + rpcService, + options: { + source: 'metamask', + }, }) - : createFetchMiddleware({ - btoa: global.btoa, - fetch: global.fetch, - rpcUrl: networkConfig.rpcUrl, - }); + : createFetchMiddleware({ rpcService }); const rpcProvider = providerFromMiddleware(rpcApiMiddleware); const blockTrackerOpts = - process.env.IN_TEST && networkConfig.type === 'custom' + process.env.IN_TEST && configuration.type === NetworkClientType.Custom ? { pollingInterval: SECOND } : {}; const blockTracker = new PollingBlockTracker({ @@ -80,16 +101,16 @@ export function createNetworkClient( }); const networkMiddleware = - networkConfig.type === NetworkClientType.Infura + configuration.type === NetworkClientType.Infura ? createInfuraNetworkMiddleware({ blockTracker, - network: networkConfig.network, + network: configuration.network, rpcProvider, rpcApiMiddleware, }) : createCustomNetworkMiddleware({ blockTracker, - chainId: networkConfig.chainId, + chainId: configuration.chainId, rpcApiMiddleware, }); @@ -105,7 +126,7 @@ export function createNetworkClient( blockTracker.destroy(); }; - return { configuration: networkConfig, provider, blockTracker, destroy }; + return { configuration, provider, blockTracker, destroy }; } /** diff --git a/packages/network-controller/src/index.ts b/packages/network-controller/src/index.ts index 51e17ce1084..3ec1ff120dc 100644 --- a/packages/network-controller/src/index.ts +++ b/packages/network-controller/src/index.ts @@ -52,3 +52,4 @@ export type { } from './types'; export { NetworkClientType } from './types'; export type { NetworkClient } from './create-network-client'; +export type { AbstractRpcService } from './rpc-service/abstract-rpc-service'; diff --git a/packages/network-controller/src/rpc-service/abstract-rpc-service.ts b/packages/network-controller/src/rpc-service/abstract-rpc-service.ts new file mode 100644 index 00000000000..d3bc82dc400 --- /dev/null +++ b/packages/network-controller/src/rpc-service/abstract-rpc-service.ts @@ -0,0 +1,67 @@ +import type { ServicePolicy } from '@metamask/controller-utils'; +import type { + Json, + JsonRpcParams, + JsonRpcRequest, + JsonRpcResponse, +} from '@metamask/utils'; + +import type { AddToCockatielEventData, FetchOptions } from './shared'; + +/** + * The interface for a service class responsible for making a request to an RPC + * endpoint. + */ +export type AbstractRpcService = { + /** + * Listens for when the RPC service retries the request. + * + * @param listener - The callback to be called when the retry occurs. + * @returns What {@link ServicePolicy.onRetry} returns. + * @see {@link createServicePolicy} + */ + onRetry( + listener: AddToCockatielEventData< + Parameters[0], + { endpointUrl: string } + >, + ): ReturnType; + + /** + * Listens for when the RPC service retries the request too many times in a + * row. + * + * @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 } + >, + ): ReturnType; + + /** + * Listens for when the policy underlying this RPC service detects a slow + * request. + * + * @param listener - The callback to be called when the request is slow. + * @returns What {@link ServicePolicy.onDegraded} returns. + * @see {@link createServicePolicy} + */ + onDegraded( + listener: AddToCockatielEventData< + Parameters[0], + { endpointUrl: string } + >, + ): ReturnType; + + /** + * Makes a request to the RPC endpoint. + */ + request( + jsonRpcRequest: JsonRpcRequest, + fetchOptions?: FetchOptions, + ): Promise>; +}; 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 new file mode 100644 index 00000000000..269baf3387b --- /dev/null +++ b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts @@ -0,0 +1,684 @@ +import nock from 'nock'; +import { useFakeTimers } from 'sinon'; +import type { SinonFakeTimers } from 'sinon'; + +import { RpcServiceChain } from './rpc-service-chain'; + +describe('RpcServiceChain', () => { + let clock: SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + describe('onRetry', () => { + it('returns a listener which can be disposed', () => { + const rpcServiceChain = new RpcServiceChain({ + fetch, + btoa, + serviceConfigurations: [ + { + endpointUrl: 'https://rpc.example.chain', + }, + ], + }); + + const onRetryListener = rpcServiceChain.onRetry(() => { + // do whatever + }); + expect(onRetryListener.dispose()).toBeUndefined(); + }); + }); + + describe('onBreak', () => { + it('returns a listener which can be disposed', () => { + const rpcServiceChain = new RpcServiceChain({ + fetch, + btoa, + serviceConfigurations: [ + { + endpointUrl: 'https://rpc.example.chain', + }, + ], + }); + + const onBreakListener = rpcServiceChain.onBreak(() => { + // do whatever + }); + expect(onBreakListener.dispose()).toBeUndefined(); + }); + }); + + describe('onDegraded', () => { + it('returns a listener which can be disposed', () => { + const rpcServiceChain = new RpcServiceChain({ + fetch, + btoa, + serviceConfigurations: [ + { + endpointUrl: 'https://rpc.example.chain', + }, + ], + }); + + const onDegradedListener = rpcServiceChain.onDegraded(() => { + // do whatever + }); + expect(onDegradedListener.dispose()).toBeUndefined(); + }); + }); + + describe('request', () => { + it('returns what the first RPC service in the chain returns, if it succeeds', async () => { + nock('https://first.chain') + .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, + serviceConfigurations: [ + { + endpointUrl: 'https://first.chain', + }, + { + endpointUrl: 'https://second.chain', + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + }, + { + endpointUrl: 'https://third.chain', + }, + ], + }); + + const response = await rpcServiceChain.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + }); + + 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) + .reply(503); + nock('https://second.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock('https://third.chain') + .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, + serviceConfigurations: [ + { + endpointUrl: 'https://first.chain', + }, + { + endpointUrl: 'https://second.chain', + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + }, + { + 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(); + }); + + 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( + 'Gateway timeout', + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // 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( + 'Gateway timeout', + ); + // 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( + 'Gateway timeout', + ); + // 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. + const response = await rpcServiceChain.request(jsonRpcRequest); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + }); + + 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', { + reqheaders: { + 'X-Fizz': 'Buzz', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + const secondEndpointScope = nock('https://second.chain', { + reqheaders: { + 'X-Foo': 'Bar', + 'X-Fizz': 'Buzz', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + const thirdEndpointScope = nock('https://third.chain', { + reqheaders: { + 'X-Foo': 'Bar', + 'X-Fizz': 'Buzz', + }, + }) + .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, + serviceConfigurations: [ + { + endpointUrl: 'https://first.chain', + }, + { + endpointUrl: 'https://second.chain', + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + }, + { + endpointUrl: 'https://third.chain', + fetchOptions: { + referrer: 'https://some.referrer', + }, + }, + ], + }); + 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 jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + const fetchOptions = { + headers: { + 'X-Fizz': 'Buzz', + }, + }; + // Retry the first endpoint until max retries is hit. + await expect( + rpcServiceChain.request(jsonRpcRequest, fetchOptions), + ).rejects.toThrow('Gateway timeout'); + // Retry the first endpoint again, until max retries is hit. + await expect( + rpcServiceChain.request(jsonRpcRequest, fetchOptions), + ).rejects.toThrow('Gateway timeout'); + // 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('Gateway timeout'); + // 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('Gateway timeout'); + // 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, fetchOptions); + + expect(firstEndpointScope.isDone()).toBe(true); + expect(secondEndpointScope.isDone()).toBe(true); + expect(thirdEndpointScope.isDone()).toBe(true); + }); + + it('calls onRetry each time an RPC service in the chain retries its request', async () => { + nock('https://first.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock('https://second.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock('https://third.chain') + .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, + serviceConfigurations: [ + { + endpointUrl: 'https://first.chain', + }, + { + endpointUrl: 'https://second.chain', + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + }, + { + endpointUrl: 'https://third.chain', + }, + ], + }); + 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(); + }); + rpcServiceChain.onRetry(onRetryListener); + + 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( + 'Gateway timeout', + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // 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( + 'Gateway timeout', + ); + // 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( + 'Gateway timeout', + ); + // 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); + + const onRetryListenerCallCountsByEndpointUrl = + onRetryListener.mock.calls.reduce( + (memo, call) => { + const { endpointUrl } = call[0]; + // There is nothing wrong with this. + // eslint-disable-next-line jest/no-conditional-in-test + memo[endpointUrl] = (memo[endpointUrl] ?? 0) + 1; + return memo; + }, + {} as Record, + ); + + expect(onRetryListenerCallCountsByEndpointUrl).toStrictEqual({ + 'https://first.chain/': 12, + 'https://second.chain/': 12, + }); + }); + + it('calls onBreak each time the underlying circuit for each RPC service in the chain breaks', async () => { + nock('https://first.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock('https://second.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock('https://third.chain') + .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, + serviceConfigurations: [ + { + endpointUrl: 'https://first.chain', + }, + { + endpointUrl: 'https://second.chain', + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + }, + { + endpointUrl: 'https://third.chain', + }, + ], + }); + 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(); + }); + 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( + 'Gateway timeout', + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // 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( + 'Gateway timeout', + ); + // 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( + 'Gateway timeout', + ); + // 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/', + }), + ); + }); + + 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') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock('https://second.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock('https://third.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + clock.tick(6000); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + + const rpcServiceChain = new RpcServiceChain({ + fetch, + btoa, + serviceConfigurations: [ + { + endpointUrl: 'https://first.chain', + }, + { + endpointUrl: 'https://second.chain', + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + }, + { + endpointUrl: 'https://third.chain', + }, + ], + }); + 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(); + }); + 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( + 'Gateway timeout', + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // 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( + 'Gateway timeout', + ); + // 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( + 'Gateway timeout', + ); + // 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); + + const onDegradedListenerCallCountsByEndpointUrl = + onDegradedListener.mock.calls.reduce( + (memo: Record, call) => { + const { endpointUrl } = call[0]; + // There is nothing wrong with this. + // eslint-disable-next-line jest/no-conditional-in-test + memo[endpointUrl] = (memo[endpointUrl] ?? 0) + 1; + return memo; + }, + {}, + ); + + expect(onDegradedListenerCallCountsByEndpointUrl).toStrictEqual({ + 'https://first.chain/': 2, + 'https://second.chain/': 2, + 'https://third.chain/': 1, + }); + }); + }); +}); diff --git a/packages/network-controller/src/rpc-service/rpc-service-chain.ts b/packages/network-controller/src/rpc-service/rpc-service-chain.ts new file mode 100644 index 00000000000..4f77677eb1d --- /dev/null +++ b/packages/network-controller/src/rpc-service/rpc-service-chain.ts @@ -0,0 +1,209 @@ +import type { + Json, + JsonRpcParams, + JsonRpcRequest, + JsonRpcResponse, +} from '@metamask/utils'; + +import type { AbstractRpcService } from './abstract-rpc-service'; +import { RpcService } from './rpc-service'; +import type { FetchOptions } from './shared'; + +/** + * The subset of options accepted by the RpcServiceChain constructor which + * represent a single endpoint. + */ +type RpcServiceConfiguration = { + /** + * The URL of the endpoint. + */ + endpointUrl: URL | string; + /** + * The options to pass to `fetch` when making the request to the endpoint. + */ + fetchOptions?: FetchOptions; +}; + +/** + * 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. + */ +export class RpcServiceChain implements AbstractRpcService { + readonly #services: RpcService[]; + + /** + * Constructs a new RpcServiceChain object. + * + * @param args - The arguments. + * @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.btoa - A function that can be used to convert a binary string + * into base-64. Used to encode authorization credentials. + * @param args.serviceConfigurations - The options for the RPC services that + * you want to construct. This class takes a set of configuration objects and + * not literal `RpcService`s to account for the possibility that we may want + * to send request headers to official Infura endpoints and not failovers. + */ + constructor({ + fetch: givenFetch, + btoa: givenBtoa, + serviceConfigurations, + }: { + fetch: typeof fetch; + btoa: typeof btoa; + serviceConfigurations: RpcServiceConfiguration[]; + }) { + this.#services = this.#buildRpcServiceChain({ + serviceConfigurations, + fetch: givenFetch, + btoa: givenBtoa, + }); + } + + /** + * Listens for when any of the RPC services retry a request. + * + * @param listener - The callback to be called when the retry occurs. + * @returns What {@link RpcService.onRetry} returns. + */ + onRetry(listener: Parameters[0]) { + const disposables = this.#services.map((service) => + service.onRetry(listener), + ); + + return { + dispose() { + disposables.forEach((disposable) => disposable.dispose()); + }, + }; + } + + /** + * Listens for when any of the RPC services retry the request too many times + * in a row. + * + * @param listener - The callback to be called when the retry occurs. + * @returns What {@link RpcService.onBreak} returns. + */ + onBreak(listener: Parameters[0]) { + const disposables = this.#services.map((service) => + service.onBreak(listener), + ); + + return { + dispose() { + disposables.forEach((disposable) => disposable.dispose()); + }, + }; + } + + /** + * Listens for when any of the RPC services send a slow request. + * + * @param listener - The callback to be called when the retry occurs. + * @returns What {@link RpcService.onRetry} returns. + */ + onDegraded(listener: Parameters[0]) { + const disposables = this.#services.map((service) => + service.onDegraded(listener), + ); + + return { + dispose() { + disposables.forEach((disposable) => disposable.dispose()); + }, + }; + } + + /** + * 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. + * + * This overload is specifically designed for `eth_getBlockByNumber`, which + * can return a `result` of `null` despite an expected `Result` being + * provided. + * + * @param jsonRpcRequest - The JSON-RPC request to send to the endpoint. + * @param fetchOptions - An options bag for {@link fetch} which further + * specifies the request. + * @returns The decoded JSON-RPC response from the endpoint. + * @throws A "method not found" error if the response status is 405. + * @throws A rate limiting error if the response HTTP status is 429. + * @throws A timeout error if the response HTTP status is 503 or 504. + * @throws A generic error if the response HTTP status is not 2xx but also not + * 405, 429, 503, or 504. + */ + async request( + jsonRpcRequest: JsonRpcRequest & { method: 'eth_getBlockByNumber' }, + fetchOptions?: FetchOptions, + ): 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. + * + * This overload is designed for all RPC methods except for + * `eth_getBlockByNumber`, which are expected to return a `result` of the + * expected `Result`. + * + * @param jsonRpcRequest - The JSON-RPC request to send to the endpoint. + * @param fetchOptions - An options bag for {@link fetch} which further + * specifies the request. + * @returns The decoded JSON-RPC response from the endpoint. + * @throws A "method not found" error if the response status is 405. + * @throws A rate limiting error if the response HTTP status is 429. + * @throws A timeout error if the response HTTP status is 503 or 504. + * @throws A generic error if the response HTTP status is not 2xx but also not + * 405, 429, 503, or 504. + */ + async request( + jsonRpcRequest: JsonRpcRequest, + fetchOptions?: FetchOptions, + ): Promise>; + + async request( + jsonRpcRequest: JsonRpcRequest, + fetchOptions: FetchOptions = {}, + ): Promise> { + return this.#services[0].request(jsonRpcRequest, fetchOptions); + } + + /** + * 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 args - The arguments. + * @param args.serviceConfigurations - The options for the RPC services that + * you want to construct. + * @param args.fetch - A function that can be used to make an HTTP request. + * @param args.btoa - A function that can be used to convert a binary string + * into base-64. Used to encode authorization credentials. + * @returns The constructed chain of RPC services. + */ + #buildRpcServiceChain({ + serviceConfigurations, + fetch: givenFetch, + btoa: givenBtoa, + }: { + serviceConfigurations: RpcServiceConfiguration[]; + fetch: typeof fetch; + btoa: typeof btoa; + }): RpcService[] { + return [...serviceConfigurations] + .reverse() + .reduce((workingServices: RpcService[], serviceConfiguration, index) => { + const failoverService = index > 0 ? workingServices[0] : undefined; + const service = new RpcService({ + fetch: givenFetch, + btoa: givenBtoa, + ...serviceConfiguration, + failoverService, + }); + return [service, ...workingServices]; + }, []); + } +} diff --git a/packages/network-controller/src/rpc-service/rpc-service.test.ts b/packages/network-controller/src/rpc-service/rpc-service.test.ts new file mode 100644 index 00000000000..3919a2623ab --- /dev/null +++ b/packages/network-controller/src/rpc-service/rpc-service.test.ts @@ -0,0 +1,1636 @@ +// We use conditions exclusively in this file. +/* eslint-disable jest/no-conditional-in-test */ + +import { rpcErrors } from '@metamask/rpc-errors'; +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 { RpcService } from './rpc-service'; +import { DEFAULT_CIRCUIT_BREAK_DURATION } from '../../../controller-utils/src/create-service-policy'; + +describe('RpcService', () => { + let clock: SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + describe('request', () => { + // NOTE: Keep this list synced with CONNECTION_ERRORS + describe.each([ + { + constructorName: 'TypeError', + message: 'network error', + }, + { + constructorName: 'TypeError', + message: 'Failed to fetch', + }, + { + constructorName: 'TypeError', + message: 'NetworkError when attempting to fetch resource.', + }, + { + constructorName: 'TypeError', + message: 'The Internet connection appears to be offline.', + }, + { + constructorName: 'TypeError', + message: 'Load failed', + }, + { + constructorName: 'TypeError', + message: 'Network request failed', + }, + { + constructorName: 'FetchError', + message: 'request to https://foo.com failed', + }, + { + constructorName: 'TypeError', + message: 'fetch failed', + }, + { + constructorName: 'TypeError', + message: 'terminated', + }, + ])( + `if making the request throws the $message error`, + ({ constructorName, message }) => { + let error; + switch (constructorName) { + case 'FetchError': + error = new FetchError(message, 'system'); + break; + case 'TypeError': + error = new TypeError(message); + break; + default: + throw new Error(`Unknown constructor ${constructorName}`); + } + testsForRetriableFetchErrors({ + getClock: () => clock, + producedError: error, + expectedError: error, + }); + }, + ); + + describe('if making the request throws a "Gateway timeout" error', () => { + const error = new Error('Gateway timeout'); + testsForRetriableFetchErrors({ + getClock: () => clock, + producedError: error, + expectedError: error, + }); + }); + + describe.each(['ETIMEDOUT', 'ECONNRESET'])( + '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 + // still used by Node. + error.code = errorCode; + + testsForRetriableFetchErrors({ + getClock: () => clock, + producedError: error, + expectedError: error, + }); + }, + ); + + 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(); + }); + }); + + 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(); + }); + }); + + 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(); + }); + }); + + describe.each([503, 504])( + 'if the endpoint has a %d response', + (httpStatus) => { + testsForRetriableResponses({ + getClock: () => clock, + httpStatus, + expectedError: rpcErrors.internal({ + message: + 'Gateway timeout. The request took too long to process. This can happen when querying logs over too wide a block range.', + }), + }); + }, + ); + + describe('if the endpoint has a 405 response', () => { + it('throws a non-existent method error without retrying the request', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_unknownMethod', + params: [], + }) + .reply(405); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + + const promise = service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_unknownMethod', + params: [], + }); + await expect(promise).rejects.toThrow( + 'The method does not exist / is not available.', + ); + }); + + 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_unknownMethod', + params: [], + }) + .reply(405); + const failoverService = buildMockRpcService(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + failoverService, + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_unknownMethod', + 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_unknownMethod', + params: [], + }) + .reply(405); + 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 endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(429); + 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('Request is being rate limited.'); + }); + + 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(429); + 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('when the endpoint has a response that is neither 2xx, nor 405, 429, 503, or 504', () => { + it('throws a generic 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(500, { + id: 1, + jsonrpc: '2.0', + error: 'oops', + }); + 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({ + message: "Non-200 status code: '500'", + data: { + id: 1, + jsonrpc: '2.0', + error: 'oops', + }, + }), + ); + }); + + 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(500, { + id: 1, + jsonrpc: '2.0', + error: 'oops', + }); + 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(500, { + id: 1, + jsonrpc: '2.0', + error: 'oops', + }); + 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('if the endpoint consistently responds with invalid JSON', () => { + testsForRetriableResponses({ + getClock: () => clock, + httpStatus: 200, + responseBody: 'invalid JSON', + expectedError: expect.objectContaining({ + message: expect.stringContaining('is not valid JSON'), + }), + }); + }); + + it('removes non-JSON-RPC-compliant properties from the request body before sending it to the endpoint', 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: '0x1', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + + // @ts-expect-error Intentionally passing bad input. + const response = await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + some: 'extra', + properties: 'here', + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + }); + + it('extracts a username and password from the URL to the Authorization header', async () => { + nock('https://rpc.example.chain', { + reqheaders: { + Authorization: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://username:password@rpc.example.chain', + }); + + const response = await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + }); + + it('makes the request with Accept and Content-Type headers by default', async () => { + const scope = nock('https://rpc.example.chain', { + reqheaders: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://username:password@rpc.example.chain', + }); + + await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(scope.isDone()).toBe(true); + }); + + it('mixes the given request options into the default request options', async () => { + const scope = nock('https://rpc.example.chain', { + reqheaders: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Foo': 'Bar', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://username:password@rpc.example.chain', + }); + + await service.request( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }, + { + headers: { + 'X-Foo': 'Bar', + }, + }, + ); + + expect(scope.isDone()).toBe(true); + }); + + it('returns the JSON-decoded response if the request succeeds', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + params: ['0x68b3', false], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: { + number: '0x68b3', + hash: '0xd5f1812548be429cbdc6376b29611fc49e06f1359758c4ceaaa3b393e2239f9c', + nonce: '0x378da40ff335b070', + gasLimit: '0x47e7c4', + gasUsed: '0x37993', + timestamp: '0x5835c54d', + transactions: [ + '0xa0807e117a8dd124ab949f460f08c36c72b710188f01609595223b325e58e0fc', + '0xeae6d797af50cb62a596ec3939114d63967c374fa57de9bc0f4e2b576ed6639d', + ], + baseFeePerGas: '0x7', + }, + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + + const response = await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + params: ['0x68b3', false], + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: { + number: '0x68b3', + hash: '0xd5f1812548be429cbdc6376b29611fc49e06f1359758c4ceaaa3b393e2239f9c', + nonce: '0x378da40ff335b070', + gasLimit: '0x47e7c4', + gasUsed: '0x37993', + timestamp: '0x5835c54d', + transactions: [ + '0xa0807e117a8dd124ab949f460f08c36c72b710188f01609595223b325e58e0fc', + '0xeae6d797af50cb62a596ec3939114d63967c374fa57de9bc0f4e2b576ed6639d', + ], + baseFeePerGas: '0x7', + }, + }); + }); + + it('does not throw if the endpoint returns an unsuccessful JSON-RPC response', 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', + error: { + code: -32000, + message: 'oops', + }, + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + + const response = await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + error: { + code: -32000, + message: 'oops', + }, + }); + }); + + it('interprets a "Not Found" response for eth_getBlockByNumber as an empty result', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + params: ['0x999999999', false], + }) + .reply(200, 'Not Found'); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + + const response = await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + params: ['0x999999999', false], + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: null, + }); + }); + + it('calls the onDegraded callback if the endpoint takes more than 5 seconds to respond', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + clock.tick(6000); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const onDegradedListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + service.onDegraded(onDegradedListener); + + await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + }); +}); + +/** + * Some tests involve a rejected promise that is not necessarily the focus of + * the test. In these cases we don't want to ignore the error in case the + * promise _isn't_ rejected, but we don't want to highlight the assertion, + * either. + * + * @param promiseOrFn - A promise that rejects, or a function that returns a + * promise that rejects. + */ +async function ignoreRejection( + promiseOrFn: Promise | (() => T | Promise), +) { + await expect(promiseOrFn).rejects.toThrow(expect.any(Error)); +} + +/** + * 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; +}) { + 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); + }); + + 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(); + }); + + 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, + ); + }); + + 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}/`, + }); + }); + }); + + 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(); + }); + + 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); + }); + + 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; + 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(); + }); + + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + expect(mockFetch).toHaveBeenCalledTimes(5); + + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + expect(mockFetch).toHaveBeenCalledTimes(10); + + // 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), + }, + ); + + 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), + }, + ); + + 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), + }, + ); + + 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); + }); + + 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 failoverService = buildMockRpcService(); + 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); + + 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); + + expect(onBreakListener).toHaveBeenCalledTimes(2); + expect(onBreakListener).toHaveBeenCalledWith({ + error: expectedError, + endpointUrl: `${endpointUrl}/`, + }); + }); + }); +} + +/** + * These are tests that exercise logic for cases in which the request returns a + * response that is retriable. + * + * @param args - The arguments + * @param args.getClock - A function that returns the Sinon clock, set in + * `beforeEach`. + * @param args.httpStatus - The HTTP status code that the response will have. + * @param args.responseBody - The body that the response will have. + * @param args.expectedError - The error that a call to the service's `request` + * method is expected to produce. + */ +function testsForRetriableResponses({ + getClock, + httpStatus, + responseBody = '', + expectedError, +}: { + getClock: () => SinonFakeTimers; + httpStatus: number; + responseBody?: string; + 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/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(); + }); + + 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); + }); + + 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 ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + }); + + 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 ignoreRejection(service.request(jsonRpcRequest)); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ + error: expectedError, + 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 = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + expect(scope.isDone()).toBe(true); + }); + + 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); + }); + + 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 failoverService = buildMockRpcService(); + 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 = { + 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); + + expect(onBreakListener).toHaveBeenCalledTimes(2); + expect(onBreakListener).toHaveBeenCalledWith({ + error: expectedError, + endpointUrl: `${endpointUrl}/`, + }); + }); + }); + + /* eslint-enable jest/no-identical-title */ +} + +/** + * Constructs a fake RPC service for use as a failover in tests. + * + * @returns The fake failover service. + */ +function buildMockRpcService(): AbstractRpcService { + return { + request: jest.fn(), + onRetry: jest.fn(), + onBreak: jest.fn(), + onDegraded: jest.fn(), + }; +} diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts new file mode 100644 index 00000000000..3ed12715ff6 --- /dev/null +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -0,0 +1,496 @@ +import type { ServicePolicy } from '@metamask/controller-utils'; +import { + CircuitState, + createServicePolicy, + handleWhen, +} from '@metamask/controller-utils'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcRequest } from '@metamask/utils'; +import { + hasProperty, + type Json, + type JsonRpcParams, + type JsonRpcResponse, +} from '@metamask/utils'; +import deepmerge from 'deepmerge'; + +import type { AbstractRpcService } from './abstract-rpc-service'; +import type { AddToCockatielEventData, FetchOptions } from './shared'; + +/** + * The list of error messages that represent a failure to connect to the network. + * + * This list was derived from Sindre Sorhus's `is-network-error` package: + * + */ +export const CONNECTION_ERRORS = [ + // Chrome + { + constructorName: 'TypeError', + pattern: /network error/u, + }, + // Chrome + { + constructorName: 'TypeError', + pattern: /Failed to fetch/u, + }, + // Firefox + { + constructorName: 'TypeError', + pattern: /NetworkError when attempting to fetch resource\./u, + }, + // Safari 16 + { + constructorName: 'TypeError', + pattern: /The Internet connection appears to be offline\./u, + }, + // Safari 17+ + { + constructorName: 'TypeError', + pattern: /Load failed/u, + }, + // `cross-fetch` + { + constructorName: 'TypeError', + pattern: /Network request failed/u, + }, + // `node-fetch` + { + constructorName: 'FetchError', + pattern: /request to (.+) failed/u, + }, + // Undici (Node.js) + { + constructorName: 'TypeError', + pattern: /fetch failed/u, + }, + // Undici (Node.js) + { + constructorName: 'TypeError', + pattern: /terminated/u, + }, +]; + +/** + * Determines whether the given error represents a failure to reach the network + * after request parameters have been validated. + * + * This is somewhat difficult to verify because JavaScript engines (and in + * some cases libraries) produce slightly different error messages for this + * particular scenario, and we need to account for this. + * + * @param error - The error. + * @returns True if the error indicates that the network cannot be connected to, + * and false otherwise. + */ +export default function isConnectionError(error: unknown) { + if (!(typeof error === 'object' && error !== null && 'message' in error)) { + return false; + } + + const { message } = error; + + return ( + typeof message === 'string' && + !isNockError(message) && + CONNECTION_ERRORS.some(({ constructorName, pattern }) => { + return ( + error.constructor.name === constructorName && pattern.test(message) + ); + }) + ); +} + +/** + * Determines whether the given error message refers to a Nock error. + * + * It's important that if we failed to mock a request in a test, the resulting + * error does not cause the request to be retried so that we can see it right + * away. + * + * @param message - The error message to test. + * @returns True if the message indicates a missing Nock mock, false otherwise. + */ +function isNockError(message: string) { + return message.includes('Nock:'); +} + +/** + * Guarantees a URL, even given a string. This is useful for checking components + * of that URL. + * + * @param endpointUrlOrUrlString - Either a URL object or a string that + * represents the URL of an endpoint. + * @returns A URL object. + */ +function getNormalizedEndpointUrl(endpointUrlOrUrlString: URL | string): URL { + return endpointUrlOrUrlString instanceof URL + ? endpointUrlOrUrlString + : new URL(endpointUrlOrUrlString); +} + +/** + * This class is responsible for making a request to an endpoint that implements + * the JSON-RPC protocol. It is designed to gracefully handle network and server + * failures, retrying requests using exponential backoff. It also offers a hook + * which can used to respond to slow requests. + */ +export class RpcService implements AbstractRpcService { + /** + * The function used to make an HTTP request. + */ + readonly #fetch: typeof fetch; + + /** + * The URL of the RPC endpoint. + */ + readonly #endpointUrl: URL; + + /** + * A common set of options that the request options will extend. + */ + readonly #fetchOptions: FetchOptions; + + /** + * An RPC service that represents a failover endpoint which will be invoked + * while the circuit for _this_ service is open. + */ + readonly #failoverService: AbstractRpcService | undefined; + + /** + * The policy that wraps the request. + */ + readonly #policy: ServicePolicy; + + /** + * Constructs a new RpcService object. + * + * @param args - The arguments. + * @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.btoa - A function that can be used to convert a binary string + * into base-64. Used to encode authorization credentials. + * @param args.endpointUrl - The URL of the RPC endpoint. + * @param args.fetchOptions - A common set of options that will be used to + * make every request. Can be overridden on the request level (e.g. to add + * headers). + * @param args.failoverService - An RPC service that represents a failover + * endpoint which will be invoked while the circuit for _this_ service is + * open. + */ + constructor({ + fetch: givenFetch, + btoa: givenBtoa, + endpointUrl, + fetchOptions = {}, + failoverService, + }: { + fetch: typeof fetch; + btoa: typeof btoa; + endpointUrl: URL | string; + fetchOptions?: FetchOptions; + failoverService?: AbstractRpcService; + }) { + this.#fetch = givenFetch; + this.#endpointUrl = getNormalizedEndpointUrl(endpointUrl); + this.#fetchOptions = this.#getDefaultFetchOptions( + this.#endpointUrl, + fetchOptions, + givenBtoa, + ); + this.#failoverService = failoverService; + + const policy = createServicePolicy({ + maxRetries: 4, + maxConsecutiveFailures: 15, + retryFilterPolicy: handleWhen((error) => { + return ( + // Ignore errors where the request failed to establish + isConnectionError(error) || + // Ignore server sent HTML error pages or truncated JSON responses + error.message.includes('not valid JSON') || + // Ignore server overload errors + error.message.includes('Gateway timeout') || + (hasProperty(error, 'code') && + (error.code === 'ETIMEDOUT' || error.code === 'ECONNRESET')) + ); + }), + }); + this.#policy = policy; + } + + /** + * Listens for when the RPC service retries the request. + * + * @param listener - The callback to be called when the retry occurs. + * @returns What {@link ServicePolicy.onRetry} returns. + * @see {@link createServicePolicy} + */ + onRetry( + listener: AddToCockatielEventData< + Parameters[0], + { endpointUrl: string } + >, + ) { + return this.#policy.onRetry((data) => { + listener({ ...data, endpointUrl: this.#endpointUrl.toString() }); + }); + } + + /** + * Listens for when the RPC service retries the request too many times in a + * row. + * + * @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 } + >, + ) { + return this.#policy.onBreak((data) => { + listener({ ...data, endpointUrl: this.#endpointUrl.toString() }); + }); + } + + /** + * Listens for when the policy underlying this RPC service detects a slow + * request. + * + * @param listener - The callback to be called when the request is slow. + * @returns What {@link ServicePolicy.onDegraded} returns. + * @see {@link createServicePolicy} + */ + onDegraded( + listener: AddToCockatielEventData< + Parameters[0], + { endpointUrl: string } + >, + ) { + return this.#policy.onDegraded(() => { + listener({ 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). + * + * This overload is specifically designed for `eth_getBlockByNumber`, which + * can return a `result` of `null` despite an expected `Result` being + * provided. + * + * @param jsonRpcRequest - The JSON-RPC request to send to the endpoint. + * @param fetchOptions - An options bag for {@link fetch} which further + * specifies the request. + * @returns The decoded JSON-RPC response from the endpoint. + * @throws A "method not found" error if the response status is 405. + * @throws A rate limiting error if the response HTTP status is 429. + * @throws A timeout error if the response HTTP status is 503 or 504. + * @throws A generic error if the response HTTP status is not 2xx but also not + * 405, 429, 503, or 504. + */ + async request( + jsonRpcRequest: JsonRpcRequest & { method: 'eth_getBlockByNumber' }, + fetchOptions?: FetchOptions, + ): 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). + * + * This overload is designed for all RPC methods except for + * `eth_getBlockByNumber`, which are expected to return a `result` of the + * expected `Result`. + * + * @param jsonRpcRequest - The JSON-RPC request to send to the endpoint. + * @param fetchOptions - An options bag for {@link fetch} which further + * specifies the request. + * @returns The decoded JSON-RPC response from the endpoint. + * @throws A "method not found" error if the response status is 405. + * @throws A rate limiting error if the response HTTP status is 429. + * @throws A timeout error if the response HTTP status is 503 or 504. + * @throws A generic error if the response HTTP status is not 2xx but also not + * 405, 429, 503, or 504. + */ + async request( + jsonRpcRequest: JsonRpcRequest, + fetchOptions?: FetchOptions, + ): Promise>; + + async request( + jsonRpcRequest: JsonRpcRequest, + fetchOptions: FetchOptions = {}, + ): Promise> { + const completeFetchOptions = this.#getCompleteFetchOptions( + jsonRpcRequest, + fetchOptions, + ); + + try { + return await this.#executePolicy( + jsonRpcRequest, + completeFetchOptions, + ); + } catch (error) { + if ( + this.#policy.circuitBreakerPolicy.state === CircuitState.Open && + this.#failoverService !== undefined + ) { + return await this.#failoverService.request( + jsonRpcRequest, + completeFetchOptions, + ); + } + throw error; + } + } + + /** + * Constructs a default set of options to `fetch`. + * + * If a username and password are present in the URL, they are extracted to an + * Authorization header. + * + * @param endpointUrl - The endpoint URL. + * @param fetchOptions - The options to `fetch`. + * @param givenBtoa - An implementation of `btoa`. + * @returns The default fetch options. + */ + #getDefaultFetchOptions( + endpointUrl: URL, + fetchOptions: FetchOptions, + givenBtoa: (stringToEncode: string) => string, + ): FetchOptions { + if (endpointUrl.username && endpointUrl.password) { + const authString = `${endpointUrl.username}:${endpointUrl.password}`; + const encodedCredentials = givenBtoa(authString); + return deepmerge(fetchOptions, { + headers: { Authorization: `Basic ${encodedCredentials}` }, + }); + } + + return fetchOptions; + } + + /** + * Constructs a final set of options to pass to `fetch`. Note that the method + * defaults to `post`, and the JSON-RPC request is automatically JSON-encoded. + * + * @param jsonRpcRequest - The JSON-RPC request. + * @param fetchOptions - Custom `fetch` options. + * @returns The complete set of `fetch` options. + */ + #getCompleteFetchOptions( + jsonRpcRequest: JsonRpcRequest, + fetchOptions: FetchOptions, + ): FetchOptions { + const defaultOptions = { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }; + const mergedOptions = deepmerge( + defaultOptions, + deepmerge(this.#fetchOptions, fetchOptions), + ); + + const { id, jsonrpc, method, params } = jsonRpcRequest; + const body = JSON.stringify({ + id, + jsonrpc, + method, + params, + }); + + return { ...mergedOptions, body }; + } + + /** + * Makes the request using the Cockatiel policy that this service creates. + * + * @param jsonRpcRequest - The JSON-RPC request to send to the endpoint. + * @param fetchOptions - The options for `fetch`; will be combined with the + * fetch options passed to the constructor + * @returns The decoded JSON-RPC response from the endpoint. + * @throws A "method not found" error if the response status is 405. + * @throws A rate limiting error if the response HTTP status is 429. + * @throws A timeout error if the response HTTP status is 503 or 504. + * @throws A generic error if the response HTTP status is not 2xx but also not + * 405, 429, 503, or 504. + */ + async #executePolicy< + Params extends JsonRpcParams, + Result extends Json, + Request extends JsonRpcRequest = JsonRpcRequest, + >( + jsonRpcRequest: Request, + fetchOptions: FetchOptions, + ): Promise | JsonRpcResponse> { + return await this.#policy.execute(async () => { + const response = await this.#fetch(this.#endpointUrl, fetchOptions); + + if (response.status === 405) { + throw rpcErrors.methodNotFound(); + } + + if (response.status === 429) { + throw rpcErrors.internal({ message: 'Request is being rate limited.' }); + } + + if (response.status === 503 || response.status === 504) { + throw rpcErrors.internal({ + message: + 'Gateway timeout. The request took too long to process. This can happen when querying logs over too wide a block range.', + }); + } + + const text = await response.text(); + + if ( + jsonRpcRequest.method === 'eth_getBlockByNumber' && + text === 'Not Found' + ) { + return { + id: jsonRpcRequest.id, + jsonrpc: jsonRpcRequest.jsonrpc, + result: null, + }; + } + + // Type annotation: We assume that if this response is valid JSON, it's a + // valid JSON-RPC response. + let json: JsonRpcResponse; + try { + json = JSON.parse(text); + } catch (error) { + if (error instanceof SyntaxError) { + throw rpcErrors.internal({ + message: 'Could not parse response as it is not valid JSON', + data: text, + }); + } else { + throw error; + } + } + + if (!response.ok) { + throw rpcErrors.internal({ + message: `Non-200 status code: '${response.status}'`, + data: json, + }); + } + + return json; + }); + } +} diff --git a/packages/network-controller/src/rpc-service/shared.ts b/packages/network-controller/src/rpc-service/shared.ts new file mode 100644 index 00000000000..68e4c78b250 --- /dev/null +++ b/packages/network-controller/src/rpc-service/shared.ts @@ -0,0 +1,16 @@ +/** + * 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. + */ +export type AddToCockatielEventData = + EventListener extends (data: infer Data) => void + ? // Prevent Data from being split if it's a type union + [Data] extends [void] + ? (data: AdditionalData) => void + : (data: Data & AdditionalData) => void + : never; diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index 09286742791..e11448fc2d9 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -1,4 +1,7 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +// A lot of the tests in this file have conditionals. +/* eslint-disable jest/no-conditional-in-test */ + +import { Messenger } from '@metamask/base-controller'; import { BUILT_IN_NETWORKS, ChainId, @@ -43,6 +46,7 @@ import { NetworkController, RpcEndpointType, selectAvailableNetworkClientIds, + selectNetworkConfigurations, } from '../src/NetworkController'; import type { NetworkClientConfiguration, Provider } from '../src/types'; import { NetworkClientType } from '../src/types'; @@ -164,6 +168,8 @@ describe('NetworkController', () => { networkConfigurationsByChainId: {}, }, infuraProjectId: 'infura-project-id', + fetch, + btoa, }), ).toThrow( 'NetworkController state is invalid: `networkConfigurationsByChainId` cannot be empty', @@ -186,6 +192,8 @@ describe('NetworkController', () => { }, }, infuraProjectId: 'infura-project-id', + fetch, + btoa, }), ).toThrow( "NetworkController state has invalid `networkConfigurationsByChainId`: Network configuration 'Test Network' is filed under '0x1337' which does not match its `chainId` of '0x1338'", @@ -215,6 +223,8 @@ describe('NetworkController', () => { }, }, infuraProjectId: 'infura-project-id', + fetch, + btoa, }), ).toThrow( "NetworkController state has invalid `networkConfigurationsByChainId`: Network configuration 'Test Network' has a `defaultBlockExplorerUrlIndex` that does not refer to an entry in `blockExplorerUrls`", @@ -243,6 +253,8 @@ describe('NetworkController', () => { }, }, infuraProjectId: 'infura-project-id', + fetch, + btoa, }), ).toThrow( "NetworkController state has invalid `networkConfigurationsByChainId`: Network configuration 'Test Network' has a `defaultBlockExplorerUrlIndex` that does not refer to an entry in `blockExplorerUrls`", @@ -271,6 +283,8 @@ describe('NetworkController', () => { }, }, infuraProjectId: 'infura-project-id', + fetch, + btoa, }), ).toThrow( "NetworkController state has invalid `networkConfigurationsByChainId`: Network configuration 'Test Network' has a `defaultRpcEndpointIndex` that does not refer to an entry in `rpcEndpoints`", @@ -309,6 +323,8 @@ describe('NetworkController', () => { }, }, infuraProjectId: 'infura-project-id', + fetch, + btoa, }), ).toThrow( 'NetworkController state has invalid `networkConfigurationsByChainId`: Every RPC endpoint across all network configurations must have a unique `networkClientId`', @@ -331,6 +347,8 @@ describe('NetworkController', () => { }, }, infuraProjectId: 'infura-project-id', + fetch, + btoa, }), ).toThrow( "NetworkController state is invalid: `selectedNetworkClientId` 'nonexistent' does not refer to an RPC endpoint within a network configuration", @@ -589,11 +607,15 @@ describe('NetworkController', () => { const fakeNetworkClient = buildFakeClient(fakeProvider); mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - infuraProjectId, - network: infuraNetworkType, - ticker: infuraNativeTokenName, - type: NetworkClientType.Infura, + configuration: { + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClient); @@ -659,10 +681,14 @@ describe('NetworkController', () => { const fakeNetworkClient = buildFakeClient(fakeProvider); mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClient); @@ -796,18 +822,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: infuraChainId, - infuraProjectId, - network: infuraNetworkType, - ticker: infuraNativeTokenName, - type: NetworkClientType.Infura, + configuration: { + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -889,18 +923,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, - infuraProjectId, - network: InfuraNetworkType.goerli, - ticker: NetworksTicker[InfuraNetworkType.goerli], - type: NetworkClientType.Infura, + configuration: { + chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, + infuraProjectId, + network: InfuraNetworkType.goerli, + ticker: NetworksTicker[InfuraNetworkType.goerli], + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -1342,18 +1384,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: ChainId[infuraNetworkType], - infuraProjectId, - network: infuraNetworkType, - ticker: NetworksTicker[infuraNetworkType], - type: NetworkClientType.Infura, + configuration: { + chainId: ChainId[infuraNetworkType], + infuraProjectId, + network: infuraNetworkType, + ticker: NetworksTicker[infuraNetworkType], + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -1440,18 +1490,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: ChainId[infuraNetworkType], - infuraProjectId, - network: infuraNetworkType, - ticker: NetworksTicker[infuraNetworkType], - type: NetworkClientType.Infura, + configuration: { + chainId: ChainId[infuraNetworkType], + infuraProjectId, + network: infuraNetworkType, + ticker: NetworksTicker[infuraNetworkType], + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -1533,18 +1591,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: ChainId[infuraNetworkType], - infuraProjectId, - network: infuraNetworkType, - ticker: NetworksTicker[infuraNetworkType], - type: NetworkClientType.Infura, + configuration: { + chainId: ChainId[infuraNetworkType], + infuraProjectId, + network: infuraNetworkType, + ticker: NetworksTicker[infuraNetworkType], + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -1579,6 +1645,119 @@ describe('NetworkController', () => { }); }); + describe('if all subscriptions are removed from the messenger before the call to lookupNetwork completes', () => { + it('does not throw an error', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + }, + infuraProjectId, + }, + async ({ controller, messenger }) => { + const fakeProvider = buildFakeProvider([ + // Called during provider initialization + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + // Called via `lookupNetwork` directly + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + ]); + const fakeNetworkClient = buildFakeClient(fakeProvider); + mockCreateNetworkClient() + .calledWith({ + configuration: { + chainId: ChainId[infuraNetworkType], + infuraProjectId, + network: infuraNetworkType, + ticker: NetworksTicker[infuraNetworkType], + type: NetworkClientType.Infura, + }, + fetch, + btoa, + }) + .mockReturnValue(fakeNetworkClient); + await controller.initializeProvider(); + + const lookupNetworkPromise = controller.lookupNetwork(); + messenger.clearSubscriptions(); + expect(await lookupNetworkPromise).toBeUndefined(); + }, + ); + }); + }); + + describe('if removing the networkDidChange subscription fails for an unknown reason', () => { + it('re-throws the error', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + }, + infuraProjectId, + }, + async ({ controller, messenger }) => { + const fakeProvider = buildFakeProvider([ + // Called during provider initialization + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + // Called via `lookupNetwork` directly + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + ]); + const fakeNetworkClient = buildFakeClient(fakeProvider); + mockCreateNetworkClient() + .calledWith({ + configuration: { + chainId: ChainId[infuraNetworkType], + infuraProjectId, + network: infuraNetworkType, + ticker: NetworksTicker[infuraNetworkType], + type: NetworkClientType.Infura, + }, + fetch, + btoa, + }) + .mockReturnValue(fakeNetworkClient); + await controller.initializeProvider(); + + const lookupNetworkPromise = controller.lookupNetwork(); + const error = new Error('oops'); + jest + .spyOn(messenger, 'unsubscribe') + .mockImplementation((eventType) => { + // This is okay. + // eslint-disable-next-line jest/no-conditional-in-test + if (eventType === 'NetworkController:networkDidChange') { + throw error; + } + }); + await expect(lookupNetworkPromise).rejects.toThrow(error); + }, + ); + }); + }); + lookupNetworkTests({ expectedNetworkClientType: NetworkClientType.Infura, initialState: { @@ -1657,18 +1836,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: ChainId[InfuraNetworkType.goerli], - infuraProjectId, - network: InfuraNetworkType.goerli, - ticker: NetworksTicker[InfuraNetworkType.goerli], - type: NetworkClientType.Infura, + configuration: { + chainId: ChainId[InfuraNetworkType.goerli], + infuraProjectId, + network: InfuraNetworkType.goerli, + ticker: NetworksTicker[InfuraNetworkType.goerli], + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -1756,18 +1943,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: ChainId[InfuraNetworkType.goerli], - infuraProjectId, - network: InfuraNetworkType.goerli, - ticker: NetworksTicker[InfuraNetworkType.goerli], - type: NetworkClientType.Infura, + configuration: { + chainId: ChainId[InfuraNetworkType.goerli], + infuraProjectId, + network: InfuraNetworkType.goerli, + ticker: NetworksTicker[InfuraNetworkType.goerli], + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -1854,18 +2049,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: ChainId[InfuraNetworkType.goerli], - infuraProjectId, - network: InfuraNetworkType.goerli, - ticker: NetworksTicker[InfuraNetworkType.goerli], - type: NetworkClientType.Infura, + configuration: { + chainId: ChainId[InfuraNetworkType.goerli], + infuraProjectId, + network: InfuraNetworkType.goerli, + ticker: NetworksTicker[InfuraNetworkType.goerli], + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -1889,48 +2092,183 @@ describe('NetworkController', () => { }); }); - lookupNetworkTests({ - expectedNetworkClientType: NetworkClientType.Custom, - initialState: { - selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', - networkConfigurationsByChainId: { - '0x1337': buildCustomNetworkConfiguration({ - chainId: '0x1337', - nativeCurrency: 'TEST', - rpcEndpoints: [ - buildCustomRpcEndpoint({ - networkClientId: 'AAAA-AAAA-AAAA-AAAA', - url: 'https://test.network', - }), - ], - }), - }, - }, - operation: async (controller) => { - await controller.lookupNetwork(); - }, - }); - }); - }); - - describe('setProviderType', () => { - for (const infuraNetworkType of Object.values(InfuraNetworkType)) { - // False negative - this is a string. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - describe(`given the Infura network "${infuraNetworkType}"`, () => { - refreshNetworkTests({ - expectedNetworkClientConfiguration: - buildInfuraNetworkClientConfiguration(infuraNetworkType), - operation: async (controller) => { - await controller.setProviderType(infuraNetworkType); - }, - }); - }); + describe('if all subscriptions are removed from the messenger before the call to lookupNetwork completes', () => { + it('does not throw an error', async () => { + const infuraProjectId = 'some-infura-project-id'; - // False negative - this is a string. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - it(`sets selectedNetworkClientId in state to "${infuraNetworkType}"`, async () => { - await withController(async ({ controller }) => { + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + }, + }, + infuraProjectId, + }, + async ({ controller, messenger }) => { + const fakeProvider = buildFakeProvider([ + // Called during provider initialization + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + // Called via `lookupNetwork` directly + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + ]); + const fakeNetworkClient = buildFakeClient(fakeProvider); + mockCreateNetworkClient() + .calledWith({ + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, + }) + .mockReturnValue(fakeNetworkClient); + await controller.initializeProvider(); + + const lookupNetworkPromise = controller.lookupNetwork(); + messenger.clearSubscriptions(); + expect(await lookupNetworkPromise).toBeUndefined(); + }, + ); + }); + }); + + describe('if removing the networkDidChange subscription fails for an unknown reason', () => { + it('re-throws the error', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + }, + }, + infuraProjectId, + }, + async ({ controller, messenger }) => { + const fakeProvider = buildFakeProvider([ + // Called during provider initialization + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + // Called via `lookupNetwork` directly + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + ]); + const fakeNetworkClient = buildFakeClient(fakeProvider); + mockCreateNetworkClient() + .calledWith({ + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, + }) + .mockReturnValue(fakeNetworkClient); + await controller.initializeProvider(); + + const lookupNetworkPromise = controller.lookupNetwork(); + const error = new Error('oops'); + jest + .spyOn(messenger, 'unsubscribe') + .mockImplementation((eventType) => { + // This is okay. + // eslint-disable-next-line jest/no-conditional-in-test + if (eventType === 'NetworkController:networkDidChange') { + throw error; + } + }); + await expect(lookupNetworkPromise).rejects.toThrow(error); + }, + ); + }); + }); + + lookupNetworkTests({ + expectedNetworkClientType: NetworkClientType.Custom, + initialState: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + }, + }, + operation: async (controller) => { + await controller.lookupNetwork(); + }, + }); + }); + }); + + describe('setProviderType', () => { + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + describe(`given the Infura network "${infuraNetworkType}"`, () => { + refreshNetworkTests({ + expectedNetworkClientConfiguration: + buildInfuraNetworkClientConfiguration(infuraNetworkType), + operation: async (controller) => { + await controller.setProviderType(infuraNetworkType); + }, + }); + }); + + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + it(`sets selectedNetworkClientId in state to "${infuraNetworkType}"`, async () => { + await withController(async ({ controller }) => { mockCreateNetworkClient().mockReturnValue(buildFakeClient()); await controller.setProviderType(infuraNetworkType); @@ -2690,10 +3028,7 @@ describe('NetworkController', () => { messenger, chainId, }: { - messenger: ControllerMessenger< - NetworkControllerActions, - NetworkControllerEvents - >; + messenger: Messenger; chainId: Hex; }) => messenger.call( @@ -2808,10 +3143,7 @@ describe('NetworkController', () => { messenger, networkClientId, }: { - messenger: ControllerMessenger< - NetworkControllerActions, - NetworkControllerEvents - >; + messenger: Messenger; networkClientId: NetworkClientId; }) => messenger.call( @@ -3357,29 +3689,41 @@ describe('NetworkController', () => { expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( 2, { - infuraProjectId, - chainId: infuraChainId, - network: infuraNetworkType, - ticker: infuraNativeTokenName, - type: NetworkClientType.Infura, + networkClientConfiguration: { + infuraProjectId, + chainId: infuraChainId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }, ); expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( 3, { - chainId: infuraChainId, - rpcUrl: 'https://test.endpoint/2', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + networkClientConfiguration: { + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }, ); expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( 4, { - chainId: infuraChainId, - rpcUrl: 'https://test.endpoint/3', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + networkClientConfiguration: { + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/3', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }, ); expect( @@ -4710,11 +5054,15 @@ describe('NetworkController', () => { expect( createAutoManagedNetworkClientSpy, ).toHaveBeenNthCalledWith(3, { - chainId: infuraChainId, - infuraProjectId: 'some-infura-project-id', - network: infuraNetworkType, - ticker: infuraNativeTokenName, - type: NetworkClientType.Infura, + networkClientConfiguration: { + chainId: infuraChainId, + infuraProjectId: 'some-infura-project-id', + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }); expect( @@ -4933,18 +5281,26 @@ describe('NetworkController', () => { expect( createAutoManagedNetworkClientSpy, ).toHaveBeenNthCalledWith(3, { - chainId: infuraChainId, - rpcUrl: 'https://rpc.endpoint/1', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + networkClientConfiguration: { + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint/1', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }); expect( createAutoManagedNetworkClientSpy, ).toHaveBeenNthCalledWith(4, { - chainId: infuraChainId, - rpcUrl: 'https://rpc.endpoint/2', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + networkClientConfiguration: { + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }); expect( @@ -5324,17 +5680,25 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://test.network/1', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://test.network/1', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://test.network/2', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://test.network/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -5428,17 +5792,25 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://test.network/1', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://test.network/1', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://test.network/2', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://test.network/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -5551,24 +5923,36 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://test.network/1', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://test.network/1', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://test.network/2', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://test.network/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]) .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://test.network/3', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://test.network/3', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[2]); await controller.initializeProvider(); @@ -5677,24 +6061,36 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://test.network/1', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://test.network/1', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://test.network/2', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://test.network/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]) .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://test.network/3', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://test.network/3', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[2]); await controller.initializeProvider(); @@ -5782,10 +6178,14 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://some.other.url', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); const existingNetworkClient = controller.getNetworkClientById( @@ -5853,10 +6253,14 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://some.other.url', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); @@ -5872,10 +6276,14 @@ describe('NetworkController', () => { }); expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ - chainId: infuraChainId, - rpcUrl: 'https://some.other.url', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + networkClientConfiguration: { + chainId: infuraChainId, + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }); expect( getNetworkConfigurationsByNetworkClientId( @@ -5928,10 +6336,14 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://some.other.url', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); @@ -6001,10 +6413,14 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://some.other.url', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); @@ -6088,17 +6504,25 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://rpc.endpoint', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://some.other.url', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -6186,17 +6610,25 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://rpc.endpoint', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://some.other.url', - ticker: infuraNativeTokenName, - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -6720,16 +7152,24 @@ describe('NetworkController', () => { }); expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ - chainId: '0x1337', - rpcUrl: 'https://rpc.endpoint/2', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + networkClientConfiguration: { + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }); expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ - chainId: '0x1337', - rpcUrl: 'https://rpc.endpoint/3', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + networkClientConfiguration: { + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint/3', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }); expect( @@ -7108,17 +7548,25 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network/2', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -7212,17 +7660,25 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network/2', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -7334,24 +7790,36 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network/2', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network/3', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network/3', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[2]); await controller.initializeProvider(); @@ -7460,24 +7928,36 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network/2', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network/3', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network/3', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[2]); await controller.initializeProvider(); @@ -7562,10 +8042,14 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://some.other.url', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); const existingNetworkClient = controller.getNetworkClientById( @@ -7633,10 +8117,14 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://some.other.url', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); @@ -7652,10 +8140,14 @@ describe('NetworkController', () => { }); expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ - chainId: '0x1337', - rpcUrl: 'https://some.other.url', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + networkClientConfiguration: { + chainId: '0x1337', + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }); expect( getNetworkConfigurationsByNetworkClientId( @@ -7708,10 +8200,14 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://some.other.url', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); @@ -7779,10 +8275,14 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://some.other.url', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); @@ -7867,17 +8367,25 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://some.other.url', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -7967,17 +8475,25 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://some.other.url', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -8567,10 +9083,14 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://test.endpoint/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); @@ -8647,10 +9167,14 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://test.endpoint/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); const existingNetworkClient1 = controller.getNetworkClientById( @@ -8734,10 +9258,14 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://test.endpoint/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); @@ -8747,16 +9275,24 @@ describe('NetworkController', () => { }); expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ - chainId: infuraChainId, - rpcUrl: 'https://test.endpoint/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + networkClientConfiguration: { + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }); expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ - chainId: infuraChainId, - rpcUrl: 'https://test.endpoint/2', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + networkClientConfiguration: { + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }); expect( @@ -8823,10 +9359,14 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://test.endpoint/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); @@ -8908,17 +9448,25 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -9002,17 +9550,25 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -9189,10 +9745,14 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.endpoint/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); @@ -9281,10 +9841,14 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.endpoint/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); const existingNetworkClient1 = controller.getNetworkClientById( @@ -9382,10 +9946,14 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.endpoint/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); @@ -9402,16 +9970,24 @@ describe('NetworkController', () => { ); expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.endpoint/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + networkClientConfiguration: { + chainId: '0x1337', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }); expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.endpoint/2', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + networkClientConfiguration: { + chainId: '0x1337', + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }); expect( @@ -9484,10 +10060,14 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.endpoint/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); @@ -9578,17 +10158,25 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -9672,17 +10260,25 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -9861,11 +10457,15 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: anotherInfuraChainId, - infuraProjectId: 'some-infura-project-id', - network: anotherInfuraNetworkType, - ticker: anotherInfuraNativeTokenName, - type: NetworkClientType.Infura, + configuration: { + chainId: anotherInfuraChainId, + infuraProjectId: 'some-infura-project-id', + network: anotherInfuraNetworkType, + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); @@ -9964,11 +10564,15 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: anotherInfuraChainId, - infuraProjectId: 'some-infura-project-id', - network: anotherInfuraNetworkType, - ticker: anotherInfuraNativeTokenName, - type: NetworkClientType.Infura, + configuration: { + chainId: anotherInfuraChainId, + infuraProjectId: 'some-infura-project-id', + network: anotherInfuraNetworkType, + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); const existingNetworkClient1 = controller.getNetworkClientById( @@ -10067,11 +10671,15 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: anotherInfuraChainId, - infuraProjectId: 'some-infura-project-id', - network: anotherInfuraNetworkType, - ticker: anotherInfuraNativeTokenName, - type: NetworkClientType.Infura, + configuration: { + chainId: anotherInfuraChainId, + infuraProjectId: 'some-infura-project-id', + network: anotherInfuraNetworkType, + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); @@ -10088,16 +10696,24 @@ describe('NetworkController', () => { }); expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ - chainId: anotherInfuraChainId, - rpcUrl: 'https://test.endpoint/1', - ticker: anotherInfuraNativeTokenName, - type: NetworkClientType.Custom, + networkClientConfiguration: { + chainId: anotherInfuraChainId, + rpcUrl: 'https://test.endpoint/1', + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }); expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ - chainId: anotherInfuraChainId, - rpcUrl: 'https://test.endpoint/2', - ticker: anotherInfuraNativeTokenName, - type: NetworkClientType.Custom, + networkClientConfiguration: { + chainId: anotherInfuraChainId, + rpcUrl: 'https://test.endpoint/2', + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Custom, + }, + fetch, + btoa, }); expect( @@ -10171,11 +10787,15 @@ describe('NetworkController', () => { async ({ controller }) => { mockCreateNetworkClient() .calledWith({ - chainId: anotherInfuraChainId, - infuraProjectId: 'some-infura-project-id', - network: anotherInfuraNetworkType, - ticker: anotherInfuraNativeTokenName, - type: NetworkClientType.Infura, + configuration: { + chainId: anotherInfuraChainId, + infuraProjectId: 'some-infura-project-id', + network: anotherInfuraNetworkType, + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(buildFakeClient()); @@ -10270,17 +10890,25 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: anotherInfuraChainId, - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: anotherInfuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -10364,17 +10992,25 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: infuraChainId, - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: anotherInfuraChainId, - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: anotherInfuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -10566,10 +11202,14 @@ describe('NetworkController', () => { const fakeNetworkClients = [buildFakeClient(fakeProviders[0])]; mockCreateNetworkClient() .calledWith({ - chainId: '0x2448', - rpcUrl: 'https://test.endpoint/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x2448', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]); @@ -10657,10 +11297,14 @@ describe('NetworkController', () => { const fakeNetworkClients = [buildFakeClient(fakeProviders[0])]; mockCreateNetworkClient() .calledWith({ - chainId: '0x2448', - rpcUrl: 'https://test.endpoint/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x2448', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]); const existingNetworkClient1 = controller.getNetworkClientById( @@ -10752,10 +11396,14 @@ describe('NetworkController', () => { const fakeNetworkClients = [buildFakeClient(fakeProviders[0])]; mockCreateNetworkClient() .calledWith({ - chainId: '0x2448', - rpcUrl: 'https://test.endpoint/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x2448', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]); @@ -10765,16 +11413,24 @@ describe('NetworkController', () => { }); expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ - chainId: '0x2448', - rpcUrl: 'https://test.endpoint/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + networkClientConfiguration: { + chainId: '0x2448', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }); expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ - chainId: '0x2448', - rpcUrl: 'https://test.endpoint/2', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + networkClientConfiguration: { + chainId: '0x2448', + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }); expect( @@ -10854,10 +11510,14 @@ describe('NetworkController', () => { const fakeNetworkClients = [buildFakeClient(fakeProviders[0])]; mockCreateNetworkClient() .calledWith({ - chainId: '0x2448', - rpcUrl: 'https://test.endpoint/1', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x2448', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]); @@ -10940,17 +11600,25 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x2448', - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x2448', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -11033,17 +11701,25 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x2448', - rpcUrl: 'https://rpc.endpoint', - ticker: 'TOKEN', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x2448', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); @@ -11363,14 +12039,17 @@ describe('NetworkController', () => { }, }, async ({ controller, messenger }) => { - - const stateChangePromise = new Promise((resolve) => { + const stateChangePromise = new Promise< + NetworkConfiguration | undefined + >((resolve) => { messenger.subscribe('NetworkController:stateChange', (state) => { const { networkClientId } = state.networkConfigurationsByChainId['0x1'].rpcEndpoints[1]; resolve( - controller.getNetworkConfigurationByNetworkClientId(networkClientId), + controller.getNetworkConfigurationByNetworkClientId( + networkClientId, + ), ); }); }); @@ -11829,18 +12508,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: infuraChainId, - infuraProjectId, - network: infuraNetworkType, - ticker: infuraNativeTokenName, - type: NetworkClientType.Infura, + configuration: { + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); @@ -11899,18 +12586,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: infuraChainId, - infuraProjectId, - network: infuraNetworkType, - ticker: infuraNativeTokenName, - type: NetworkClientType.Infura, + configuration: { + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); @@ -11986,18 +12681,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: infuraChainId, - infuraProjectId, - network: infuraNetworkType, - ticker: infuraNativeTokenName, - type: NetworkClientType.Infura, + configuration: { + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); @@ -12048,18 +12751,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: infuraChainId, - infuraProjectId, - network: infuraNetworkType, - ticker: infuraNativeTokenName, - type: NetworkClientType.Infura, + configuration: { + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); @@ -12119,18 +12830,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: infuraChainId, - infuraProjectId, - network: infuraNetworkType, - ticker: infuraNativeTokenName, - type: NetworkClientType.Infura, + configuration: { + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); @@ -12202,18 +12921,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: infuraChainId, - infuraProjectId, - network: infuraNetworkType, - ticker: infuraNativeTokenName, - type: NetworkClientType.Infura, + configuration: { + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); @@ -12288,18 +13015,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: infuraChainId, - infuraProjectId, - network: infuraNetworkType, - ticker: infuraNativeTokenName, - type: NetworkClientType.Infura, + configuration: { + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); @@ -12449,18 +13184,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: ChainId.goerli, - infuraProjectId, - network: InfuraNetworkType.goerli, - ticker: NetworksTicker.goerli, - type: NetworkClientType.Infura, + configuration: { + chainId: ChainId.goerli, + infuraProjectId, + network: InfuraNetworkType.goerli, + ticker: NetworksTicker.goerli, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.setActiveNetwork(InfuraNetworkType.goerli); @@ -12519,18 +13262,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: ChainId.goerli, - infuraProjectId, - network: InfuraNetworkType.goerli, - ticker: NetworksTicker.goerli, - type: NetworkClientType.Infura, + configuration: { + chainId: ChainId.goerli, + infuraProjectId, + network: InfuraNetworkType.goerli, + ticker: NetworksTicker.goerli, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.setActiveNetwork(InfuraNetworkType.goerli); @@ -12612,18 +13363,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: ChainId.goerli, - infuraProjectId, - network: InfuraNetworkType.goerli, - ticker: NetworksTicker.goerli, - type: NetworkClientType.Infura, + configuration: { + chainId: ChainId.goerli, + infuraProjectId, + network: InfuraNetworkType.goerli, + ticker: NetworksTicker.goerli, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.setActiveNetwork(InfuraNetworkType.goerli); @@ -12675,18 +13434,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: ChainId.goerli, - infuraProjectId, - network: InfuraNetworkType.goerli, - ticker: NetworksTicker.goerli, - type: NetworkClientType.Infura, + configuration: { + chainId: ChainId.goerli, + infuraProjectId, + network: InfuraNetworkType.goerli, + ticker: NetworksTicker.goerli, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.setActiveNetwork(InfuraNetworkType.goerli); @@ -12737,18 +13504,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: ChainId.goerli, - infuraProjectId, - network: InfuraNetworkType.goerli, - ticker: NetworksTicker.goerli, - type: NetworkClientType.Infura, + configuration: { + chainId: ChainId.goerli, + infuraProjectId, + network: InfuraNetworkType.goerli, + ticker: NetworksTicker.goerli, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.setActiveNetwork(InfuraNetworkType.goerli); @@ -12816,18 +13591,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: ChainId.goerli, - infuraProjectId, - network: InfuraNetworkType.goerli, - ticker: NetworksTicker.goerli, - type: NetworkClientType.Infura, + configuration: { + chainId: ChainId.goerli, + infuraProjectId, + network: InfuraNetworkType.goerli, + ticker: NetworksTicker.goerli, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.setActiveNetwork(InfuraNetworkType.goerli); @@ -12898,18 +13681,26 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: ChainId.goerli, - infuraProjectId, - network: InfuraNetworkType.goerli, - ticker: NetworksTicker.goerli, - type: NetworkClientType.Infura, + configuration: { + chainId: ChainId.goerli, + infuraProjectId, + network: InfuraNetworkType.goerli, + ticker: NetworksTicker.goerli, + type: NetworkClientType.Infura, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://test.network', - ticker: 'TEST', - type: NetworkClientType.Custom, + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + fetch, + btoa, }) .mockReturnValue(fakeNetworkClients[1]); await controller.setActiveNetwork(InfuraNetworkType.goerli); @@ -13024,6 +13815,24 @@ describe('getNetworkConfigurations', () => { }); }); +describe('selectNetworkConfigurations', () => { + it('returns network configurations available in the state', () => { + const state = getDefaultNetworkControllerState(); + + expect(selectNetworkConfigurations(state)).toStrictEqual( + Object.values(state.networkConfigurationsByChainId), + ); + }); + + it('is memoized', () => { + const state = getDefaultNetworkControllerState(); + + expect(selectNetworkConfigurations(state)).toBe( + selectNetworkConfigurations(state), + ); + }); +}); + describe('getAvailableNetworkClientIds', () => { it('returns network client ids available in the state', () => { const networkConfigurations = [ @@ -13206,10 +14015,14 @@ function refreshNetworkTests({ await operation(controller); expect(createNetworkClientMock).toHaveBeenCalledWith({ - chainId: expectedNetworkClientConfiguration.chainId, - rpcUrl: expectedNetworkClientConfiguration.rpcUrl, - type: NetworkClientType.Custom, - ticker: expectedNetworkClientConfiguration.ticker, + configuration: { + chainId: expectedNetworkClientConfiguration.chainId, + rpcUrl: expectedNetworkClientConfiguration.rpcUrl, + type: NetworkClientType.Custom, + ticker: expectedNetworkClientConfiguration.ticker, + }, + fetch, + btoa, }); const { provider } = controller.getProviderAndBlockTracker(); assert(provider); @@ -13248,8 +14061,12 @@ function refreshNetworkTests({ await operation(controller); expect(createNetworkClientMock).toHaveBeenCalledWith({ - ...expectedNetworkClientConfiguration, - infuraProjectId: 'infura-project-id', + configuration: { + ...expectedNetworkClientConfiguration, + infuraProjectId: 'infura-project-id', + }, + fetch, + btoa, }); const { provider } = controller.getProviderAndBlockTracker(); assert(provider); @@ -13280,7 +14097,7 @@ function refreshNetworkTests({ ]; const { selectedNetworkClientId } = controller.state; let initializationNetworkClientConfiguration: - | Parameters[0] + | Parameters[0]['configuration'] | undefined; for (const matchingNetworkConfiguration of Object.values( @@ -13319,7 +14136,7 @@ function refreshNetworkTests({ const operationNetworkClientConfiguration: Parameters< typeof createNetworkClient - >[0] = + >[0]['configuration'] = expectedNetworkClientConfiguration.type === NetworkClientType.Custom ? expectedNetworkClientConfiguration : { @@ -13327,9 +14144,17 @@ function refreshNetworkTests({ infuraProjectId: 'infura-project-id', }; mockCreateNetworkClient() - .calledWith(initializationNetworkClientConfiguration) + .calledWith({ + configuration: initializationNetworkClientConfiguration, + fetch, + btoa, + }) .mockReturnValue(fakeNetworkClients[0]) - .calledWith(operationNetworkClientConfiguration) + .calledWith({ + configuration: operationNetworkClientConfiguration, + fetch, + btoa, + }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); const { provider: providerBefore } = @@ -14130,22 +14955,19 @@ function lookupNetworkTests({ } /** - * Build a controller messenger that includes all events used by the network + * Build a messenger that includes all events used by the network * controller. * - * @returns The controller messenger. + * @returns The messenger. */ function buildMessenger() { - return new ControllerMessenger< - NetworkControllerActions, - NetworkControllerEvents - >(); + return new Messenger(); } /** - * Build a restricted controller messenger for the network controller. + * Build a restricted messenger for the network controller. * - * @param messenger - A controller messenger. + * @param messenger - A messenger. * @returns The network controller restricted messenger. */ function buildNetworkControllerMessenger(messenger = buildMessenger()) { @@ -14160,10 +14982,7 @@ type WithControllerCallback = ({ controller, }: { controller: NetworkController; - messenger: ControllerMessenger< - NetworkControllerActions, - NetworkControllerEvents - >; + messenger: Messenger; }) => Promise | ReturnValue; type WithControllerOptions = Partial; @@ -14191,6 +15010,8 @@ async function withController( const controller = new NetworkController({ messenger: restrictedMessenger, infuraProjectId: 'infura-project-id', + fetch, + btoa, ...rest, }); try { @@ -14233,7 +15054,8 @@ function buildFakeClient( * optionally provided for certain RPC methods. * * @param stubs - The list of RPC methods you want to stub along with their - * responses. `eth_getBlockByNumber` will be stubbed by default. + * responses. `eth_getBlockByNumber` and `eth_blockNumber will be stubbed by + * default. * @returns The object. */ function buildFakeProvider(stubs: FakeProviderStub[] = []): Provider { @@ -14335,10 +15157,7 @@ async function waitForPublishedEvents({ // do nothing }, }: { - messenger: ControllerMessenger< - NetworkControllerActions, - NetworkControllerEvents - >; + messenger: Messenger; eventType: E['type']; count?: number; filter?: (payload: E['payload']) => boolean; @@ -14469,10 +15288,7 @@ async function waitForStateChanges({ operation, beforeResolving, }: { - messenger: ControllerMessenger< - NetworkControllerActions, - NetworkControllerEvents - >; + messenger: Messenger; propertyPath?: string[]; count?: number; wait?: number; diff --git a/packages/network-controller/tests/provider-api-tests/block-param.ts b/packages/network-controller/tests/provider-api-tests/block-param.ts index 16e5cc22799..6cf6f7af20f 100644 --- a/packages/network-controller/tests/provider-api-tests/block-param.ts +++ b/packages/network-controller/tests/provider-api-tests/block-param.ts @@ -6,11 +6,6 @@ import { withMockedCommunications, withNetworkClient, } from './helpers'; -import { - buildFetchFailedErrorMessage, - buildInfuraClientRetriesExhaustedErrorMessage, - buildJsonRpcEngineEmptyResponseErrorMessage, -} from './shared-tests'; type TestsForRpcMethodSupportingBlockParam = { providerType: ProviderType; @@ -385,167 +380,45 @@ export function testsForRpcMethodSupportingBlockParam( }); }); - // There is a difference in how we are testing the Infura middleware vs. the - // custom RPC middleware (or, more specifically, the fetch middleware) - // because of what both middleware treat as rate limiting errors. In this - // case, the fetch middleware treats a 418 response from the RPC endpoint as - // such an error, whereas to the Infura middleware, it is a 429 response. - if (providerType === 'infura') { - it('throws a generic, undescriptive error if the request to the RPC endpoint returns a 418 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - id: 123, - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - httpStatus: 418, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - '{"id":123,"jsonrpc":"2.0"}', - ); - }); - }); - - it('throws an error with a custom message if the request to the RPC endpoint returns a 429 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - httpStatus: 429, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - 'Request is being rate limited', - ); - }); - }); - } else { - it('throws an error with a custom message if the request to the RPC endpoint returns a 418 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - httpStatus: 418, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); + it('throws an error with a custom message if the request to the RPC endpoint returns a 429 response', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; - await expect(promiseForResult).rejects.toThrow( - 'Request is being rate limited.', - ); + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + httpStatus: 429, + }, }); - }); - - it('throws an undescriptive error if the request to the RPC endpoint returns a 429 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - httpStatus: 429, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => makeRpcCall(request), + ); - await expect(promiseForResult).rejects.toThrow( - "Non-200 status code: '429'", - ); - }); + await expect(promiseForResult).rejects.toThrow( + 'Request is being rate limited', + ); }); - } + }); - it('throws an undescriptive error message if the request to the RPC endpoint returns a response that is not 405, 418, 429, 503, or 504', async () => { + it('throws an undescriptive error message if the request to the RPC endpoint returns a response that is not 405, 429, 503, or 504', async () => { await withMockedCommunications({ providerType }, async (comms) => { const request = { method }; @@ -577,11 +450,9 @@ export function testsForRpcMethodSupportingBlockParam( async ({ makeRpcCall }) => makeRpcCall(request), ); - const msg = - providerType === 'infura' - ? '{"id":12345,"jsonrpc":"2.0","error":"some error"}' - : "Non-200 status code: '420'"; - await expect(promiseForResult).rejects.toThrow(msg); + await expect(promiseForResult).rejects.toThrow( + "Non-200 status code: '420'", + ); }); }); @@ -643,99 +514,47 @@ export function testsForRpcMethodSupportingBlockParam( }); }); - // Both the Infura middleware and custom RPC middleware detect a 503 or - // 504 response and retry the request to the RPC endpoint automatically - // but differ in what sort of response is returned when the number of - // retries is exhausted. - if (providerType === 'infura') { - it(`causes a request to fail with a custom error if the request to the RPC endpoint returns a ${httpStatus} response 5 times in a row`, async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - error: 'Some error', - httpStatus, - }, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - await expect(promiseForResult).rejects.toThrow( - buildInfuraClientRetriesExhaustedErrorMessage('Gateway timeout'), - ); - }); - }); - } else { - it(`produces an empty response if the request to the RPC endpoint returns a ${httpStatus} response 5 times in a row`, async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; + it(`causes a request to fail with a custom error if the request to the RPC endpoint returns a ${httpStatus} response 5 times in a row`, async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - error: 'Some error', - httpStatus, - }, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - await expect(promiseForResult).rejects.toThrow( - buildJsonRpcEngineEmptyResponseErrorMessage(method), - ); + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + error: 'Some error', + httpStatus, + }, + times: 5, }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); + await expect(promiseForResult).rejects.toThrow('Gateway timeout'); }); - } + }); }); it('retries the request to the RPC endpoint up to 5 times if an "ETIMEDOUT" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { @@ -744,6 +563,10 @@ export function testsForRpcMethodSupportingBlockParam( method, params: buildMockParams({ blockParam, blockParamIndex }), }; + const error = new Error('Request timed out'); + // @ts-expect-error `code` does not exist on the Error type, but is + // still used by Node. + error.code = 'ETIMEDOUT'; // The first time a block-cacheable request is made, the // block-cache middleware will request the latest block number @@ -764,7 +587,7 @@ export function testsForRpcMethodSupportingBlockParam( blockParamIndex, '0x100', ), - error: 'ETIMEDOUT: Some message', + error, times: 4, }); comms.mockRpcCall({ @@ -793,669 +616,345 @@ export function testsForRpcMethodSupportingBlockParam( }); }); - // Both the Infura and fetch middleware detect ETIMEDOUT errors and will - // automatically retry the request to the RPC endpoint in question, but each - // produces a different error if the number of retries is exhausted. - if (providerType === 'infura') { - it('causes a request to fail with a custom error if an "ETIMEDOUT" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'ETIMEDOUT: Some message'; + it('re-throws a "ETIMEDOUT" error produced even after making the request to the RPC endpoint 5 times in a row', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + const error = new Error('Request timed out'); + // @ts-expect-error `code` does not exist on the Error type, but is + // still used by Node. + error.code = 'ETIMEDOUT'; - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - times: 5, - }); - - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - await expect(promiseForResult).rejects.toThrow( - buildInfuraClientRetriesExhaustedErrorMessage(errorMessage), - ); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + error, + times: 5, }); - }); - } else { - it('produces an empty response if an "ETIMEDOUT" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - const errorMessage = 'ETIMEDOUT: Some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); - await expect(promiseForResult).rejects.toThrow( - buildJsonRpcEngineEmptyResponseErrorMessage(method), - ); - }); + await expect(promiseForResult).rejects.toThrow(error.message); }); - } - - // The Infura middleware treats a response that contains an ECONNRESET - // message as an innocuous error that is likely to disappear on a retry. The - // custom RPC middleware, on the other hand, does not specially handle this - // error. - if (providerType === 'infura') { - it('retries the request to the RPC endpoint up to 5 times if an "ECONNRESET" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - // - // Here we have the request fail for the first 4 tries, then - // succeed on the 5th try. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: 'ECONNRESET: Some message', - times: 4, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - result: 'the result', - httpStatus: 200, - }, - }); + }); - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); + it('retries the request to the RPC endpoint up to 5 times if an "ECONNRESET" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; + const error = new Error('Connection reset'); + // @ts-expect-error `code` does not exist on the Error type, but is + // still used by Node. + error.code = 'ECONNRESET'; - expect(result).toBe('the result'); + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + // + // Here we have the request fail for the first 4 tries, then + // succeed on the 5th try. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + error, + times: 4, + }); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + result: 'the result', + httpStatus: 200, + }, }); - }); - - it('causes a request to fail with a custom error if an "ECONNRESET" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - const errorMessage = 'ECONNRESET: Some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); - await expect(promiseForResult).rejects.toThrow( - buildInfuraClientRetriesExhaustedErrorMessage(errorMessage), - ); - }); + expect(result).toBe('the result'); }); - } else { - it('does not retry the request to the RPC endpoint, but throws immediately, if an "ECONNRESET" error is thrown while making the request', async () => { - const customRpcUrl = 'http://example.com'; + }); - await withMockedCommunications( - { providerType, customRpcUrl }, - async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - const errorMessage = 'ECONNRESET: Some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - }); + it('re-throws a "ECONNRESET" error produced even after making the request to the RPC endpoint 5 times in a row', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; + const error = new Error('Connection reset'); + // @ts-expect-error `code` does not exist on the Error type, but is + // still used by Node. + error.code = 'ECONNRESET'; - const promiseForResult = withNetworkClient( - { providerType, customRpcUrl }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + error, + times: 5, + }); - await expect(promiseForResult).rejects.toThrow( - buildFetchFailedErrorMessage(customRpcUrl, errorMessage), + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, ); }, ); + + await expect(promiseForResult).rejects.toThrow(error.message); }); - } + }); - // Both the Infura and fetch middleware will attempt to parse the response - // body as JSON, and if this step produces an error, both middleware will - // also attempt to retry the request. However, this error handling code is - // slightly different between the two. As the error in this case is a - // SyntaxError, the Infura middleware will catch it immediately, whereas the - // custom RPC middleware will catch it and re-throw a separate error, which - // it then catches later. - if (providerType === 'infura') { - it('retries the request to the RPC endpoint up to 5 times if a "SyntaxError" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; + it('retries the request to the RPC endpoint up to 5 times if the request has an invalid JSON response, returning the successful result if it is valid JSON on the 5th try', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - // - // Here we have the request fail for the first 4 tries, then - // succeed on the 5th try. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: 'SyntaxError: Some message', - times: 4, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - result: 'the result', - httpStatus: 200, - }, - }); - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - expect(result).toBe('the result'); + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + // + // Here we have the request fail for the first 4 tries, then + // succeed on the 5th try. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + body: 'invalid JSON', + }, + times: 4, }); - }); - - it('causes a request to fail with a custom error if a "SyntaxError" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - const errorMessage = 'SyntaxError: Some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - times: 5, - }); - - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - await expect(promiseForResult).rejects.toThrow( - buildInfuraClientRetriesExhaustedErrorMessage(errorMessage), - ); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + result: 'the result', + httpStatus: 200, + }, }); - }); - - it('does not retry the request to the RPC endpoint, but throws immediately, if a "failed to parse response body" error is thrown while making the request', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - const errorMessage = 'failed to parse response body: Some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - }); - - const promiseForResult = withNetworkClient( - { providerType, infuraNetwork: comms.infuraNetwork }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); - await expect(promiseForResult).rejects.toThrow( - buildFetchFailedErrorMessage(comms.rpcUrl, errorMessage), - ); - }); + expect(result).toBe('the result'); }); - } else { - it('does not retry the request to the RPC endpoint, but throws immediately, if a "SyntaxError" error is thrown while making the request', async () => { - const customRpcUrl = 'http://example.com'; + }); - await withMockedCommunications( - { providerType, customRpcUrl }, - async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - const errorMessage = 'SyntaxError: Some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - }); + it('causes a request to fail with a custom error if the request to the RPC endpoint has an invalid JSON response 5 times in a row', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; - const promiseForResult = withNetworkClient( - { providerType, customRpcUrl }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + body: 'invalid JSON', + }, + times: 5, + }); - await expect(promiseForResult).rejects.toThrow( - buildFetchFailedErrorMessage(customRpcUrl, errorMessage), + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, ); }, ); - }); - - it('retries the request to the RPC endpoint up to 5 times if a "failed to parse response body" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - // - // Here we have the request fail for the first 4 tries, then - // succeed on the 5th try. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: 'failed to parse response body: Some message', - times: 4, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - result: 'the result', - httpStatus: 200, - }, - }); - - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - expect(result).toBe('the result'); - }); + await expect(promiseForResult).rejects.toThrow('not valid JSON'); }); + }); - it('produces an empty response if a "failed to parse response body" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - const errorMessage = 'failed to parse response body: some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); + it('retries the request to the RPC endpoint up to 5 times if a connection error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; + const error = new TypeError('Failed to fetch'); - await expect(promiseForResult).rejects.toThrow( - buildJsonRpcEngineEmptyResponseErrorMessage(method), - ); + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + // + // Here we have the request fail for the first 4 tries, then + // succeed on the 5th try. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + error, + times: 4, }); - }); - } - - // Only the custom RPC middleware will detect a "Failed to fetch" error and - // attempt to retry the request to the RPC endpoint; the Infura middleware - // does not. - if (providerType === 'infura') { - it('does not retry the request to the RPC endpoint, but throws immediately, if a "Failed to fetch" error is thrown while making the request', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - const errorMessage = 'Failed to fetch: Some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - }); - - const promiseForResult = withNetworkClient( - { providerType, infuraNetwork: comms.infuraNetwork }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - buildFetchFailedErrorMessage(comms.rpcUrl, errorMessage), - ); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + result: 'the result', + httpStatus: 200, + }, }); - }); - } else { - it('retries the request to the RPC endpoint up to 5 times if a "Failed to fetch" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - // - // Here we have the request fail for the first 4 tries, then - // succeed on the 5th try. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: 'Failed to fetch: Some message', - times: 4, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - result: 'the result', - httpStatus: 200, - }, - }); - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); - expect(result).toBe('the result'); - }); + expect(result).toBe('the result'); }); + }); - it('produces an empty response if a "Failed to fetch" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'Failed to fetch: some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); + it('re-throws a connection error produced even after making the request to the RPC endpoint 5 times in a row', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + const error = new TypeError('Failed to fetch'); - await expect(promiseForResult).rejects.toThrow( - buildJsonRpcEngineEmptyResponseErrorMessage(method), - ); + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + error, + times: 5, }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); + + await expect(promiseForResult).rejects.toThrow(error.message); }); - } + }); }); describe.each([ diff --git a/packages/network-controller/tests/provider-api-tests/helpers.ts b/packages/network-controller/tests/provider-api-tests/helpers.ts index 02a749d0b07..bba49cc8181 100644 --- a/packages/network-controller/tests/provider-api-tests/helpers.ts +++ b/packages/network-controller/tests/provider-api-tests/helpers.ts @@ -79,8 +79,7 @@ type Response = { result?: any; httpStatus?: number; }; -type ResponseBody = { body: JSONRPCResponse }; -type BodyOrResponse = ResponseBody | Response; +type BodyOrResponse = { body: JSONRPCResponse | string } | Response; type CurriedMockRpcCallOptions = { request: Request; // The response data. @@ -143,7 +142,7 @@ function mockRpcCall({ // for consistency with makeRpcCall, assume that the `body` contains it const { method, params = [], ...rest } = request; let httpStatus = 200; - let completeResponse: JSONRPCResponse = { id: 2, jsonrpc: '2.0' }; + let completeResponse: JSONRPCResponse | string = { id: 2, jsonrpc: '2.0' }; if (response !== undefined) { if ('body' in response) { completeResponse = response.body; @@ -195,6 +194,10 @@ function mockRpcCall({ // 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; @@ -485,17 +488,25 @@ export async function withNetworkClient( const clientUnderTest = providerType === 'infura' ? createNetworkClient({ - network: infuraNetwork, - infuraProjectId: MOCK_INFURA_PROJECT_ID, - type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[infuraNetwork].chainId, - ticker: BUILT_IN_NETWORKS[infuraNetwork].ticker, + configuration: { + network: infuraNetwork, + infuraProjectId: MOCK_INFURA_PROJECT_ID, + type: NetworkClientType.Infura, + chainId: BUILT_IN_NETWORKS[infuraNetwork].chainId, + ticker: BUILT_IN_NETWORKS[infuraNetwork].ticker, + }, + fetch, + btoa, }) : createNetworkClient({ - chainId: customChainId, - rpcUrl: customRpcUrl, - type: NetworkClientType.Custom, - ticker: customTicker, + configuration: { + chainId: customChainId, + rpcUrl: customRpcUrl, + type: NetworkClientType.Custom, + ticker: customTicker, + }, + fetch, + btoa, }); /* eslint-disable-next-line n/no-process-env */ process.env.IN_TEST = inTest; diff --git a/packages/network-controller/tests/provider-api-tests/no-block-param.ts b/packages/network-controller/tests/provider-api-tests/no-block-param.ts index 15f4cace4cc..de13c243e56 100644 --- a/packages/network-controller/tests/provider-api-tests/no-block-param.ts +++ b/packages/network-controller/tests/provider-api-tests/no-block-param.ts @@ -4,11 +4,6 @@ import { withMockedCommunications, withNetworkClient, } from './helpers'; -import { - buildFetchFailedErrorMessage, - buildInfuraClientRetriesExhaustedErrorMessage, - buildJsonRpcEngineEmptyResponseErrorMessage, -} from './shared-tests'; type TestsForRpcMethodAssumingNoBlockParamOptions = { providerType: ProviderType; @@ -265,114 +260,32 @@ export function testsForRpcMethodAssumingNoBlockParam( }); }); - // There is a difference in how we are testing the Infura middleware vs. the - // custom RPC middleware (or, more specifically, the fetch middleware) because - // of what both middleware treat as rate limiting errors. In this case, the - // fetch middleware treats a 418 response from the RPC endpoint as such an - // error, whereas to the Infura middleware, it is a 429 response. - if (providerType === 'infura') { - it('throws an undescriptive error if the request to the RPC endpoint returns a 418 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { id: 123, method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - response: { - httpStatus: 418, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - '{"id":123,"jsonrpc":"2.0"}', - ); - }); - }); - - it('throws an error with a custom message if the request to the RPC endpoint returns a 429 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - response: { - httpStatus: 429, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - 'Request is being rate limited', - ); - }); - }); - } else { - it('throws a custom error if the request to the RPC endpoint returns a 418 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - response: { - httpStatus: 418, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); + it('throws an error with a custom message if the request to the RPC endpoint returns a 429 response', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; - await expect(promiseForResult).rejects.toThrow( - 'Request is being rate limited.', - ); + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { + httpStatus: 429, + }, }); - }); - - it('throws an undescriptive error if the request to the RPC endpoint returns a 429 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - response: { - httpStatus: 429, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => makeRpcCall(request), + ); - await expect(promiseForResult).rejects.toThrow( - "Non-200 status code: '429'", - ); - }); + await expect(promiseForResult).rejects.toThrow( + 'Request is being rate limited', + ); }); - } + }); - it('throws a generic, undescriptive error if the request to the RPC endpoint returns a response that is not 405, 418, 429, 503, or 504', async () => { + it('throws a generic, undescriptive error if the request to the RPC endpoint returns a response that is not 405, 429, 503, or 504', async () => { await withMockedCommunications({ providerType }, async (comms) => { const request = { method }; @@ -394,11 +307,9 @@ export function testsForRpcMethodAssumingNoBlockParam( async ({ makeRpcCall }) => makeRpcCall(request), ); - const errorMessage = - providerType === 'infura' - ? '{"id":12345,"jsonrpc":"2.0","error":"some error"}' - : "Non-200 status code: '420'"; - await expect(promiseForResult).rejects.toThrow(errorMessage); + await expect(promiseForResult).rejects.toThrow( + "Non-200 status code: '420'", + ); }); }); @@ -468,11 +379,7 @@ export function testsForRpcMethodAssumingNoBlockParam( ); }, ); - const err = - providerType === 'infura' - ? buildInfuraClientRetriesExhaustedErrorMessage('Gateway timeout') - : buildJsonRpcEngineEmptyResponseErrorMessage(method); - await expect(promiseForResult).rejects.toThrow(err); + await expect(promiseForResult).rejects.toThrow('Gateway timeout'); }); }); }); @@ -480,6 +387,10 @@ export function testsForRpcMethodAssumingNoBlockParam( it('retries the request to the RPC endpoint up to 5 times if an "ETIMEDOUT" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { await withMockedCommunications({ providerType }, async (comms) => { const request = { method }; + const error = new Error('Request timed out'); + // @ts-expect-error `code` does not exist on the Error type, but is + // still used by Node. + error.code = 'ETIMEDOUT'; // The first time a block-cacheable request is made, the latest block // number is retrieved through the block tracker first. It doesn't @@ -489,7 +400,7 @@ export function testsForRpcMethodAssumingNoBlockParam( // on the 5th try. comms.mockRpcCall({ request, - error: 'ETIMEDOUT: Some message', + error, times: 4, }); comms.mockRpcCall({ @@ -514,461 +425,240 @@ export function testsForRpcMethodAssumingNoBlockParam( }); }); - // Both the Infura and fetch middleware detect ETIMEDOUT errors and will - // automatically retry the request to the RPC endpoint in question, but both - // produce a different error if the number of retries is exhausted. - if (providerType === 'infura') { - it('causes a request to fail with a custom error if an "ETIMEDOUT" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'ETIMEDOUT: Some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); + it('re-throws a "ETIMEDOUT" error produced even after making the request to the RPC endpoint 5 times in a row', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + const error = new Error('Request timed out'); + // @ts-expect-error `code` does not exist on the Error type, but is + // still used by Node. + error.code = 'ETIMEDOUT'; - await expect(promiseForResult).rejects.toThrow( - buildInfuraClientRetriesExhaustedErrorMessage(errorMessage), - ); + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + error, + times: 5, }); - }); - } else { - it('returns an empty response if an "ETIMEDOUT" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'ETIMEDOUT: Some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); - await expect(promiseForResult).rejects.toThrow( - buildJsonRpcEngineEmptyResponseErrorMessage(method), - ); - }); + await expect(promiseForResult).rejects.toThrow(error.message); }); - } + }); - // The Infura middleware treats a response that contains an ECONNRESET message - // as an innocuous error that is likely to disappear on a retry. The custom - // RPC middleware, on the other hand, does not specially handle this error. - if (providerType === 'infura') { - it('retries the request to the RPC endpoint up to 5 times if an "ECONNRESET" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; + it('retries the request to the RPC endpoint up to 5 times if an "ECONNRESET" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + const error = new Error('Connection reset'); + // @ts-expect-error `code` does not exist on the Error type, but is + // still used by Node. + error.code = 'ECONNRESET'; - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - // Here we have the request fail for the first 4 tries, then succeed - // on the 5th try. - comms.mockRpcCall({ - request, - error: 'ECONNRESET: Some message', - times: 4, - }); - comms.mockRpcCall({ - request, - response: { - result: 'the result', - httpStatus: 200, - }, - }); + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + // Here we have the request fail for the first 4 tries, then succeed + // on the 5th try. + comms.mockRpcCall({ + request, + error, + times: 4, + }); + comms.mockRpcCall({ + request, + response: { + result: 'the result', + httpStatus: 200, + }, + }); - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); - expect(result).toBe('the result'); - }); + expect(result).toBe('the result'); }); + }); - it('causes a request to fail with a custom error if an "ECONNRESET" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'ECONNRESET: Some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); + it('re-throws a "ECONNRESET" error produced even after making the request to the RPC endpoint 5 times in a row', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + const error = new Error('Connection reset'); + // @ts-expect-error `code` does not exist on the Error type, but is + // still used by Node. + error.code = 'ECONNRESET'; - await expect(promiseForResult).rejects.toThrow( - buildInfuraClientRetriesExhaustedErrorMessage(errorMessage), - ); + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + error, + times: 5, }); - }); - } else { - it('does not retry the request to the RPC endpoint, but throws immediately, if an "ECONNRESET" error is thrown while making the request', async () => { - const customRpcUrl = 'http://example.com'; - - await withMockedCommunications( - { providerType, customRpcUrl }, - async (comms) => { - const request = { method }; - const errorMessage = 'ECONNRESET: Some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - }); - const promiseForResult = withNetworkClient( - { providerType, customRpcUrl }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - buildFetchFailedErrorMessage(customRpcUrl, errorMessage), + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, ); }, ); + + await expect(promiseForResult).rejects.toThrow(error.message); }); - } - - // Both the Infura and fetch middleware will attempt to parse the response - // body as JSON, and if this step produces an error, both middleware will also - // attempt to retry the request. However, this error handling code is slightly - // different between the two. As the error in this case is a SyntaxError, the - // Infura middleware will catch it immediately, whereas the custom RPC - // middleware will catch it and re-throw a separate error, which it then - // catches later. - if (providerType === 'infura') { - it('retries the request to the RPC endpoint up to 5 times if an "SyntaxError" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - // Here we have the request fail for the first 4 tries, then succeed - // on the 5th try. - comms.mockRpcCall({ - request, - error: 'SyntaxError: Some message', - times: 4, - }); - comms.mockRpcCall({ - request, - response: { - result: 'the result', - httpStatus: 200, - }, - }); + }); - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); + it('retries the request to the RPC endpoint up to 5 times if the request has an invalid JSON response, returning the successful result if it is valid JSON on the 5th try', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; - expect(result).toBe('the result'); + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + // Here we have the request fail for the first 4 tries, then succeed + // on the 5th try. + comms.mockRpcCall({ + request, + response: { + body: 'invalid JSON', + }, + times: 4, + }); + comms.mockRpcCall({ + request, + response: { + result: 'the result', + httpStatus: 200, + }, }); - }); - - it('causes a request to fail with a custom error if an "SyntaxError" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'SyntaxError: Some message'; - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); - await expect(promiseForResult).rejects.toThrow( - buildInfuraClientRetriesExhaustedErrorMessage(errorMessage), - ); - }); + expect(result).toBe('the result'); }); + }); - it('does not retry the request to the RPC endpoint, but throws immediately, if a "failed to parse response body" error is thrown while making the request', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'failed to parse response body: some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - }); - const promiseForResult = withNetworkClient( - { providerType, infuraNetwork: comms.infuraNetwork }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); + it('causes a request to fail with a custom error if the request to the RPC endpoint has an invalid JSON response 5 times in a row', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; - await expect(promiseForResult).rejects.toThrow( - buildFetchFailedErrorMessage(comms.rpcUrl, errorMessage), - ); + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { + body: 'invalid JSON', + }, + times: 5, }); - }); - } else { - it('does not retry the request to the RPC endpoint, but throws immediately, if a "SyntaxError" error is thrown while making the request', async () => { - const customRpcUrl = 'http://example.com'; - - await withMockedCommunications( - { providerType, customRpcUrl }, - async (comms) => { - const request = { method }; - const errorMessage = 'SyntaxError: Some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - }); - const promiseForResult = withNetworkClient( - { providerType, customRpcUrl }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - buildFetchFailedErrorMessage(customRpcUrl, errorMessage), + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, ); }, ); - }); - - it('retries the request to the RPC endpoint up to 5 times if a "failed to parse response body" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - // Here we have the request fail for the first 4 tries, then succeed - // on the 5th try. - comms.mockRpcCall({ - request, - error: 'failed to parse response body: some message', - times: 4, - }); - comms.mockRpcCall({ - request, - response: { - result: 'the result', - httpStatus: 200, - }, - }); - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - expect(result).toBe('the result'); - }); + await expect(promiseForResult).rejects.toThrow('not valid JSON'); }); + }); - it('returns an empty response if a "failed to parse response body" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'failed to parse response body: some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); + it('retries the request to the RPC endpoint up to 5 times if a connection error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + const error = new TypeError('Failed to fetch'); - await expect(promiseForResult).rejects.toThrow( - buildJsonRpcEngineEmptyResponseErrorMessage(method), - ); + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + // Here we have the request fail for the first 4 tries, then succeed + // on the 5th try. + comms.mockRpcCall({ + request, + error, + times: 4, }); - }); - } - - // Only the custom RPC middleware will detect a "Failed to fetch" error and - // attempt to retry the request to the RPC endpoint; the Infura middleware - // does not. - if (providerType === 'infura') { - it('does not retry the request to the RPC endpoint, but throws immediately, if a "Failed to fetch" error is thrown while making the request', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'Failed to fetch: some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - }); - const promiseForResult = withNetworkClient( - { providerType, infuraNetwork: comms.infuraNetwork }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - buildFetchFailedErrorMessage(comms.rpcUrl, errorMessage), - ); + comms.mockRpcCall({ + request, + response: { + result: 'the result', + httpStatus: 200, + }, }); - }); - } else { - it('retries the request to the RPC endpoint up to 5 times if a "Failed to fetch" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - // Here we have the request fail for the first 4 tries, then succeed - // on the 5th try. - comms.mockRpcCall({ - request, - error: 'Failed to fetch: some message', - times: 4, - }); - comms.mockRpcCall({ - request, - response: { - result: 'the result', - httpStatus: 200, - }, - }); - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); - expect(result).toBe('the result'); - }); + expect(result).toBe('the result'); }); + }); - it('returns an empty response if a "Failed to fetch" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'Failed to fetch: some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); + it('re-throws a connection error produced even after making the request to the RPC endpoint 5 times in a row', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + const error = new TypeError('Failed to fetch'); - await expect(promiseForResult).rejects.toThrow( - buildJsonRpcEngineEmptyResponseErrorMessage(method), - ); + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + error, + times: 5, }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); + + await expect(promiseForResult).rejects.toThrow(error.message); }); - } + }); } diff --git a/packages/network-controller/tests/provider-api-tests/shared-tests.ts b/packages/network-controller/tests/provider-api-tests/shared-tests.ts index 473a0ff2439..06241443d4f 100644 --- a/packages/network-controller/tests/provider-api-tests/shared-tests.ts +++ b/packages/network-controller/tests/provider-api-tests/shared-tests.ts @@ -19,35 +19,6 @@ export function buildInfuraClientRetriesExhaustedErrorMessage(reason: string) { ); } -/** - * Constructs an error message that JsonRpcEngine would produce in the event - * that the response object is empty as it leaves the middleware. - * - * @param method - The RPC method. - * @returns The error message. - */ -export function buildJsonRpcEngineEmptyResponseErrorMessage(method: string) { - return new RegExp( - `^JsonRpcEngine: Response has no error or result for request:.+"method": "${method}"`, - 'us', - ); -} - -/** - * Constructs an error message that `fetch` with throw if it cannot make a - * request. - * - * @param url - The URL being fetched - * @param reason - The reason. - * @returns The error message. - */ -export function buildFetchFailedErrorMessage(url: string, reason: string) { - return new RegExp( - `^request to ${url}(/[^/ ]*)+ failed, reason: ${reason}`, - 'us', - ); -} - /** * Defines tests that are common to both the Infura and JSON-RPC network client. * diff --git a/packages/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md index 6da44d6f950..49511638232 100644 --- a/packages/notification-services-controller/CHANGELOG.md +++ b/packages/notification-services-controller/CHANGELOG.md @@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.21.0] + +### Added + +- Lock conditional checks when initializing accounts inside the `NotificationServicesController` ([#5323](https://github.com/MetaMask/core/pull/5323)) +- Accounts initialize call when the wallet is unlocked ([#5323](https://github.com/MetaMask/core/pull/5323)) + +### Changed + +- **BREAKING:** Bump `@metamask/profile-sync-controller` peer dependency from `^7.0.0` to `^8.0.0` ([#5318](https://github.com/MetaMask/core/pull/5318)) + +## [0.20.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) + +## [0.20.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/profile-sync-controller` from `^6.0.0` to `^7.0.0` ([#5292](https://github.com/MetaMask/core/pull/5292)) + +## [0.19.0] + +### Changed + +- Improve logic & dependencies between profile sync, auth, user storage & notifications ([#5275](https://github.com/MetaMask/core/pull/5275)) +- Rename `ControllerMessenger` to `Messenger` ([#5242](https://github.com/MetaMask/core/pull/5242)) +- Bump @metamask/utils to v11.1.0 ([#5223](https://github.com/MetaMask/core/pull/5223)) + +## [0.18.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/profile-sync-controller` from `^4.0.0` to `^5.0.0` ([#5218](https://github.com/MetaMask/core/pull/5218)) + +## [0.17.0] + +### Changed + +- **BREAKING:** Bump depenency `firebase` from `^10.11.0` to `^11.2.0` ([#5196](https://github.com/MetaMask/core/pull/5196)) + ## [0.16.0] ### Changed @@ -44,12 +87,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.12.1] -### Uncategorized +### Changed + +- chore: Bump `@metamask/utils` from `^9.1.0` to `^10.0.0` ([#4831](https://github.com/MetaMask/core/pull/4831)) + +### Fixed -- fix: disable notifications ([#4890](https://github.com/MetaMask/core/pull/4890)) -- Release 236.0.0 ([#4870](https://github.com/MetaMask/core/pull/4870)) -- Release 233.0.0 ([#4862](https://github.com/MetaMask/core/pull/4862)) -- chore: Bump `@metamask/utils` ([#4831](https://github.com/MetaMask/core/pull/4831)) +- fix: allow snap notifications to be visbible when controller is disabled ([#4890](https://github.com/MetaMask/core/pull/4890)) + - Most notification services are switched off when the controller is disabled, but since snaps are "local notifications", they need to be visible irrespective to the controller disabled state. ## [0.12.0] @@ -274,7 +319,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.16.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.21.0...HEAD +[0.21.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.20.1...@metamask/notification-services-controller@0.21.0 +[0.20.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.20.0...@metamask/notification-services-controller@0.20.1 +[0.20.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.19.0...@metamask/notification-services-controller@0.20.0 +[0.19.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.18.0...@metamask/notification-services-controller@0.19.0 +[0.18.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.17.0...@metamask/notification-services-controller@0.18.0 +[0.17.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.16.0...@metamask/notification-services-controller@0.17.0 [0.16.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.15.0...@metamask/notification-services-controller@0.16.0 [0.15.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.14.0...@metamask/notification-services-controller@0.15.0 [0.14.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.13.0...@metamask/notification-services-controller@0.14.0 diff --git a/packages/notification-services-controller/jest.environment.js b/packages/notification-services-controller/jest.environment.js index 46710c45482..e70c931b98b 100644 --- a/packages/notification-services-controller/jest.environment.js +++ b/packages/notification-services-controller/jest.environment.js @@ -10,6 +10,8 @@ class CustomTestEnvironment extends JSDOMEnvironment { async setup() { await super.setup(); + // jest runs in a node environment, so need to polyfil webAPIs + // eslint-disable-next-line no-shadow, n/prefer-global/text-encoder, n/prefer-global/text-decoder const { TextEncoder, TextDecoder } = require('util'); this.global.TextEncoder = TextEncoder; this.global.TextDecoder = TextDecoder; @@ -17,6 +19,8 @@ class CustomTestEnvironment extends JSDOMEnvironment { this.global.Uint8Array = Uint8Array; if (typeof this.global.crypto === 'undefined') { + // jest runs in a node environment, so need to polyfil webAPIs + // eslint-disable-next-line n/no-unsupported-features/node-builtins this.global.crypto = require('crypto').webcrypto; } } diff --git a/packages/notification-services-controller/package.json b/packages/notification-services-controller/package.json index 028fbdb9fbc..e544c35cd02 100644 --- a/packages/notification-services-controller/package.json +++ b/packages/notification-services-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/notification-services-controller", - "version": "0.16.0", + "version": "0.21.0", "description": "Manages New MetaMask decentralized Notification system", "keywords": [ "MetaMask", @@ -100,11 +100,11 @@ }, "dependencies": { "@contentful/rich-text-html-renderer": "^16.5.2", - "@metamask/base-controller": "^7.1.1", - "@metamask/controller-utils": "^11.4.5", - "@metamask/utils": "^11.0.1", + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", + "@metamask/utils": "^11.1.0", "bignumber.js": "^9.1.2", - "firebase": "^10.11.0", + "firebase": "^11.2.0", "loglevel": "^1.8.1", "uuid": "^8.3.2" }, @@ -112,8 +112,8 @@ "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^19.0.4", - "@metamask/profile-sync-controller": "^4.1.1", + "@metamask/keyring-controller": "^19.1.0", + "@metamask/profile-sync-controller": "^8.0.0", "@types/jest": "^27.4.1", "@types/readable-stream": "^2.3.0", "contentful": "^10.15.0", @@ -128,7 +128,7 @@ }, "peerDependencies": { "@metamask/keyring-controller": "^19.0.0", - "@metamask/profile-sync-controller": "^4.0.0" + "@metamask/profile-sync-controller": "^8.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 77979ae8ce6..586c990a157 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts @@ -1,7 +1,8 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import * as ControllerUtils from '@metamask/controller-utils'; import type { KeyringControllerGetAccountsAction, + KeyringControllerGetStateAction, KeyringControllerState, } from '@metamask/keyring-controller'; import type { UserStorageController } from '@metamask/profile-sync-controller'; @@ -39,6 +40,7 @@ import type { NotificationServicesPushControllerUpdateTriggerPushNotifications, NotificationServicesControllerMessenger, NotificationServicesControllerState, + NotificationServicesPushControllerSubscribeToNotifications, } from './NotificationServicesController'; import { processFeatureAnnouncement } from './processors'; import { processNotification } from './processors/process-notifications'; @@ -186,40 +188,152 @@ describe('metamask-notifications - constructor()', () => { }); }); - it('initializes push notifications', async () => { - const { messenger, mockEnablePushNotifications } = arrangeMocks(); + const arrangeActInitialisePushNotifications = ( + modifications?: (mocks: ReturnType) => void, + ) => { + // Arrange + const mocks = arrangeMocks(); + modifications?.(mocks); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _controller = new NotificationServicesController({ - messenger, + // Act + new NotificationServicesController({ + messenger: mocks.messenger, env: { featureAnnouncements: featureAnnouncementsEnv }, state: { isNotificationServicesEnabled: true }, }); + return mocks; + }; + + it('initializes push notifications', async () => { + const { mockEnablePushNotifications } = + arrangeActInitialisePushNotifications(); + await waitFor(() => { expect(mockEnablePushNotifications).toHaveBeenCalled(); }); }); - it('fails to initialize push notifications', async () => { - const { messenger, mockPerformGetStorage, mockEnablePushNotifications } = - arrangeMocks(); + it('does not initialise push notifications if the wallet is locked', async () => { + const { mockEnablePushNotifications, mockSubscribeToPushNotifications } = + arrangeActInitialisePushNotifications((mocks) => { + mocks.mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: false, // Wallet Locked + } as MockVar); + }); + + await waitFor(() => { + expect(mockEnablePushNotifications).not.toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mockSubscribeToPushNotifications).toHaveBeenCalled(); + }); + }); - // test when user storage is empty - mockPerformGetStorage.mockResolvedValue(null); + it('should re-initialise push notifications if wallet was locked, and then is unlocked', async () => { + // Test Wallet Lock + const { + globalMessenger, + mockEnablePushNotifications, + mockSubscribeToPushNotifications, + } = arrangeActInitialisePushNotifications((mocks) => { + mocks.mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: false, // Wallet Locked + } as MockVar); + }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _controller = new NotificationServicesController({ - messenger, - env: { featureAnnouncements: featureAnnouncementsEnv }, - state: { isNotificationServicesEnabled: true }, + await waitFor(() => { + expect(mockEnablePushNotifications).not.toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mockSubscribeToPushNotifications).toHaveBeenCalled(); }); + // Test Wallet Unlock + jest.clearAllMocks(); + await globalMessenger.publish('KeyringController:unlock'); + await waitFor(() => { + expect(mockEnablePushNotifications).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mockSubscribeToPushNotifications).not.toHaveBeenCalled(); + }); + }); + + it('bails push notification initialisation if fails to get notification storage', async () => { + const { mockPerformGetStorage, mockEnablePushNotifications } = + arrangeActInitialisePushNotifications((mocks) => { + // test when user storage is empty + mocks.mockPerformGetStorage.mockResolvedValue(null); + }); + await waitFor(() => { expect(mockPerformGetStorage).toHaveBeenCalled(); }); - expect(mockEnablePushNotifications).not.toHaveBeenCalled(); + await waitFor(() => { + expect(mockEnablePushNotifications).not.toHaveBeenCalled(); + }); + }); + + const arrangeActInitialiseNotificationAccountTracking = ( + modifications?: (mocks: ReturnType) => void, + ) => { + // Arrange + const mocks = arrangeMocks(); + modifications?.(mocks); + + // Act + new NotificationServicesController({ + messenger: mocks.messenger, + env: { + featureAnnouncements: featureAnnouncementsEnv, + isPushIntegrated: false, + }, + state: { isNotificationServicesEnabled: true }, + }); + + return mocks; + }; + + it('should initialse accounts to track notifications on', async () => { + const { mockListAccounts } = + arrangeActInitialiseNotificationAccountTracking(); + await waitFor(() => { + expect(mockListAccounts).toHaveBeenCalled(); + }); + }); + + it('should not initialise accounts if wallet is locked', async () => { + const { mockListAccounts } = + arrangeActInitialiseNotificationAccountTracking((mocks) => { + mocks.mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: false, + } as MockVar); + }); + await waitFor(() => { + expect(mockListAccounts).not.toHaveBeenCalled(); + }); + }); + + it('should re-initialise if the wallet was locked, and then unlocked', async () => { + // Test Wallet Locked + const { globalMessenger, mockListAccounts } = + arrangeActInitialiseNotificationAccountTracking((mocks) => { + mocks.mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: false, + } as MockVar); + }); + await waitFor(() => { + expect(mockListAccounts).not.toHaveBeenCalled(); + }); + + // Test Wallet Unlock + jest.clearAllMocks(); + await globalMessenger.publish('KeyringController:unlock'); + await waitFor(() => { + expect(mockListAccounts).toHaveBeenCalled(); + }); }); }); @@ -808,6 +922,22 @@ describe('metamask-notifications - enableMetamaskNotifications()', () => { return { ...messengerMocks, mockCreateOnChainTriggers }; }; + it('should sign a user in if not already signed in', async () => { + const mocks = arrangeMocks(); + mocks.mockListAccounts.mockResolvedValue(['0xAddr1']); + mocks.mockIsSignedIn.mockReturnValue(false); // mock that auth is not enabled + const controller = new NotificationServicesController({ + messenger: mocks.messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.enableMetamaskNotifications(); + + expect(mocks.mockIsSignedIn).toHaveBeenCalled(); + expect(mocks.mockAuthPerformSignIn).toHaveBeenCalled(); + expect(mocks.mockIsSignedIn()).toBe(true); + }); + it('create new notifications when switched on and no new notifications', async () => { const mocks = arrangeMocks(); mocks.mockListAccounts.mockResolvedValue(['0xAddr1']); @@ -943,13 +1073,11 @@ const typedMockAction = () => /** * Jest Mock Utility - Mock Notification Messenger + * * @returns mock notification messenger and other messenger mocks */ function mockNotificationMessenger() { - const globalMessenger = new ControllerMessenger< - AllowedActions, - AllowedEvents - >(); + const globalMessenger = new Messenger(); const messenger = globalMessenger.getRestricted({ name: 'NotificationServicesController', @@ -958,13 +1086,14 @@ function mockNotificationMessenger() { 'KeyringController:getState', 'AuthenticationController:getBearerToken', 'AuthenticationController:isSignedIn', + 'AuthenticationController:performSignIn', 'NotificationServicesPushController:disablePushNotifications', 'NotificationServicesPushController:enablePushNotifications', 'NotificationServicesPushController:updateTriggerPushNotifications', + 'NotificationServicesPushController:subscribeToPushNotifications', 'UserStorageController:getStorageKey', 'UserStorageController:performGetStorage', 'UserStorageController:performSetStorage', - 'UserStorageController:enableProfileSyncing', ], allowedEvents: [ 'KeyringController:stateChange', @@ -987,6 +1116,11 @@ function mockNotificationMessenger() { true, ); + const mockAuthPerformSignIn = + typedMockAction().mockResolvedValue( + 'New Access Token', + ); + const mockDisablePushNotifications = typedMockAction(); @@ -996,14 +1130,14 @@ function mockNotificationMessenger() { const mockUpdateTriggerPushNotifications = typedMockAction(); + const mockSubscribeToPushNotifications = + typedMockAction(); + const mockGetStorageKey = typedMockAction().mockResolvedValue( 'MOCK_STORAGE_KEY', ); - const mockEnableProfileSyncing = - typedMockAction(); - const mockPerformGetStorage = typedMockAction().mockResolvedValue( JSON.stringify(createMockFullUserStorage()), @@ -1012,6 +1146,11 @@ function mockNotificationMessenger() { const mockPerformSetStorage = typedMockAction(); + const mockKeyringControllerGetState = + typedMockAction().mockReturnValue({ + isUnlocked: true, + } as MockVar); + jest.spyOn(messenger, 'call').mockImplementation((...args) => { const [actionType] = args; @@ -1024,7 +1163,7 @@ function mockNotificationMessenger() { } if (actionType === 'KeyringController:getState') { - return { isUnlocked: true } as MockVar; + return mockKeyringControllerGetState(); } if (actionType === 'AuthenticationController:getBearerToken') { @@ -1035,6 +1174,11 @@ function mockNotificationMessenger() { return mockIsSignedIn(); } + if (actionType === 'AuthenticationController:performSignIn') { + mockIsSignedIn.mockReturnValue(true); + return mockAuthPerformSignIn(); + } + if ( actionType === 'NotificationServicesPushController:disablePushNotifications' @@ -1056,12 +1200,15 @@ function mockNotificationMessenger() { return mockUpdateTriggerPushNotifications(params[0]); } - if (actionType === 'UserStorageController:getStorageKey') { - return mockGetStorageKey(); + if ( + actionType === + 'NotificationServicesPushController:subscribeToPushNotifications' + ) { + return mockSubscribeToPushNotifications(); } - if (actionType === 'UserStorageController:enableProfileSyncing') { - return mockEnableProfileSyncing(); + if (actionType === 'UserStorageController:getStorageKey') { + return mockGetStorageKey(); } if (actionType === 'UserStorageController:performGetStorage') { @@ -1083,17 +1230,21 @@ function mockNotificationMessenger() { mockListAccounts, mockGetBearerToken, mockIsSignedIn, + mockAuthPerformSignIn, mockDisablePushNotifications, mockEnablePushNotifications, mockUpdateTriggerPushNotifications, + mockSubscribeToPushNotifications, mockGetStorageKey, mockPerformGetStorage, mockPerformSetStorage, + mockKeyringControllerGetState, }; } /** * Jest Mock Utility - Mock Auth Failure Assertions + * * @param mocks - mock messenger * @returns mock test auth scenarios */ @@ -1118,6 +1269,7 @@ function arrangeFailureAuthAssertions( /** * Jest Mock Utility - Mock User Storage Failure Assertions + * * @param mocks - mock messenger * @returns mock test user storage key scenarios (e.g. no storage key, rejected storage key) */ @@ -1137,6 +1289,7 @@ function arrangeFailureUserStorageKeyAssertions( /** * Jest Mock Utility - Mock User Storage Failure Assertions + * * @param mocks - mock messenger * @returns mock test user storage scenarios */ diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts index f349dfdf5e3..b925fb9463e 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts @@ -1,5 +1,5 @@ import type { - RestrictedControllerMessenger, + RestrictedMessenger, ControllerGetStateAction, ControllerStateChangeEvent, StateMetadata, @@ -227,8 +227,8 @@ export type AllowedActions = // Auth Controller Requests | AuthenticationController.AuthenticationControllerGetBearerToken | AuthenticationController.AuthenticationControllerIsSignedIn + | AuthenticationController.AuthenticationControllerPerformSignIn // User Storage Controller Requests - | UserStorageController.UserStorageControllerEnableProfileSyncing | UserStorageController.UserStorageControllerGetStorageKey | UserStorageController.UserStorageControllerPerformGetStorage | UserStorageController.UserStorageControllerPerformSetStorage @@ -271,14 +271,13 @@ export type AllowedEvents = | NotificationServicesPushControllerOnNewNotification; // Type for the messenger of NotificationServicesController -export type NotificationServicesControllerMessenger = - RestrictedControllerMessenger< - typeof controllerName, - Actions | AllowedActions, - Events | AllowedEvents, - AllowedActions['type'], - AllowedEvents['type'] - >; +export type NotificationServicesControllerMessenger = RestrictedMessenger< + typeof controllerName, + Actions | AllowedActions, + Events | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; type FeatureAnnouncementEnv = { spaceId: string; @@ -295,7 +294,7 @@ export default class NotificationServicesController extends BaseController< NotificationServicesControllerMessenger > { // Temporary boolean as push notifications are not yet enabled on mobile - #isPushIntegrated = true; + readonly #isPushIntegrated: boolean = true; // Flag to check is notifications have been setup when the browser/extension is initialized. // We want to re-initialize push notifications when the browser/extension is refreshed @@ -304,7 +303,7 @@ export default class NotificationServicesController extends BaseController< #isUnlocked = false; - #keyringController = { + readonly #keyringController = { setupLockedStateSubscriptions: (onUnlock: () => Promise) => { const { isUnlocked } = this.messagingSystem.call( 'KeyringController:getState', @@ -325,7 +324,7 @@ export default class NotificationServicesController extends BaseController< }, }; - #auth = { + readonly #auth = { getBearerToken: async () => { return await this.messagingSystem.call( 'AuthenticationController:getBearerToken', @@ -334,14 +333,14 @@ export default class NotificationServicesController extends BaseController< isSignedIn: () => { return this.messagingSystem.call('AuthenticationController:isSignedIn'); }, - }; - - #storage = { - enableProfileSyncing: async () => { + signIn: async () => { return await this.messagingSystem.call( - 'UserStorageController:enableProfileSyncing', + 'AuthenticationController:performSignIn', ); }, + }; + + readonly #storage = { getStorageKey: () => { return this.messagingSystem.call('UserStorageController:getStorageKey'); }, @@ -360,7 +359,7 @@ export default class NotificationServicesController extends BaseController< }, }; - #pushNotifications = { + readonly #pushNotifications = { subscribeToPushNotifications: async () => { await this.messagingSystem.call( 'NotificationServicesPushController:subscribeToPushNotifications', @@ -445,7 +444,10 @@ export default class NotificationServicesController extends BaseController< }, }; - #accounts = { + readonly #accounts = { + // Flag to ensure we only setup once + isNotificationAccountsSetup: false, + /** * Used to get list of addresses from keyring (wallet addresses) * @@ -493,8 +495,11 @@ export default class NotificationServicesController extends BaseController< * * @returns result from list accounts */ - initialize: () => { - return this.#accounts.listAccounts(); + initialize: async (): Promise => { + if (this.#isUnlocked && !this.#accounts.isNotificationAccountsSetup) { + await this.#accounts.listAccounts(); + this.#accounts.isNotificationAccountsSetup = true; + } }, /** @@ -505,7 +510,7 @@ export default class NotificationServicesController extends BaseController< subscribe: () => { this.messagingSystem.subscribe( 'KeyringController:stateChange', - // eslint-disable-next-line @typescript-eslint/no-misused-promises + async () => { if (!this.state.isNotificationServicesEnabled) { return; @@ -527,7 +532,7 @@ export default class NotificationServicesController extends BaseController< }, }; - #featureAnnouncementEnv: FeatureAnnouncementEnv; + readonly #featureAnnouncementEnv: FeatureAnnouncementEnv; /** * Creates a NotificationServicesController instance. @@ -563,9 +568,10 @@ export default class NotificationServicesController extends BaseController< this.#registerMessageHandlers(); this.#clearLoadingStates(); - this.#keyringController.setupLockedStateSubscriptions( - this.#pushNotifications.initializePushNotifications, - ); + this.#keyringController.setupLockedStateSubscriptions(async () => { + await this.#accounts.initialize(); + await this.#pushNotifications.initializePushNotifications(); + }); // eslint-disable-next-line @typescript-eslint/no-floating-promises this.#accounts.initialize(); // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -632,15 +638,6 @@ export default class NotificationServicesController extends BaseController< return { bearerToken, storageKey }; } - #performEnableProfileSyncing = async () => { - try { - await this.#storage.enableProfileSyncing(); - } catch (e) { - log.error('Failed to enable profile syncing', e); - throw new Error('Failed to enable profile syncing'); - } - }; - #assertUserStorage( storage: UserStorage | null, ): asserts storage is UserStorage { @@ -669,7 +666,7 @@ export default class NotificationServicesController extends BaseController< try { const userStorage: UserStorage = JSON.parse(userStorageString); return userStorage; - } catch (error) { + } catch { log.error('Unable to parse User Storage'); return null; } @@ -825,8 +822,6 @@ export default class NotificationServicesController extends BaseController< try { this.#setIsUpdatingMetamaskNotifications(true); - await this.#performEnableProfileSyncing(); - const { bearerToken, storageKey } = await this.#getValidStorageKeyAndBearerToken(); @@ -897,6 +892,12 @@ export default class NotificationServicesController extends BaseController< public async enableMetamaskNotifications() { try { this.#setIsUpdatingMetamaskNotifications(true); + + const isSignedIn = this.#auth.isSignedIn(); + if (!isSignedIn) { + await this.#auth.signIn(); + } + await this.createOnChainTriggers(); } catch (e) { log.error('Unable to enable notifications', e); @@ -1088,6 +1089,7 @@ export default class NotificationServicesController extends BaseController< * **Action** - When a user views the notification list page/dropdown * * @param previewToken - the preview token to use if needed + * @returns A promise that resolves to the list of notifications. * @throws {Error} Throws an error if unauthenticated or from other operations. */ public async fetchAndUpdateMetamaskNotifications( diff --git a/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mock-raw-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mock-raw-notifications.ts index 73586923321..5ed07c66996 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mock-raw-notifications.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mock-raw-notifications.ts @@ -1,9 +1,9 @@ -/* eslint-disable @typescript-eslint/naming-convention */ import { TRIGGER_TYPES } from '../constants/notification-schema'; import type { OnChainRawNotification } from '../types/on-chain-notification/on-chain-notification'; /** * Mocking Utility - create a mock Eth sent notification + * * @returns Mock raw Eth sent notification */ export function createMockNotificationEthSent(): OnChainRawNotification { @@ -39,6 +39,7 @@ export function createMockNotificationEthSent(): OnChainRawNotification { /** * Mocking Utility - create a mock Eth Received notification + * * @returns Mock raw Eth Received notification */ export function createMockNotificationEthReceived(): OnChainRawNotification { @@ -74,6 +75,7 @@ export function createMockNotificationEthReceived(): OnChainRawNotification { /** * Mocking Utility - create a mock ERC20 sent notification + * * @returns Mock raw ERC20 sent notification */ export function createMockNotificationERC20Sent(): OnChainRawNotification { @@ -115,6 +117,7 @@ export function createMockNotificationERC20Sent(): OnChainRawNotification { /** * Mocking Utility - create a mock ERC20 received notification + * * @returns Mock raw ERC20 received notification */ export function createMockNotificationERC20Received(): OnChainRawNotification { @@ -156,6 +159,7 @@ export function createMockNotificationERC20Received(): OnChainRawNotification { /** * Mocking Utility - create a mock ERC721 sent notification + * * @returns Mock raw ERC721 sent notification */ export function createMockNotificationERC721Sent(): OnChainRawNotification { @@ -200,6 +204,7 @@ export function createMockNotificationERC721Sent(): OnChainRawNotification { /** * Mocking Utility - create a mock ERC721 received notification + * * @returns Mock raw ERC721 received notification */ export function createMockNotificationERC721Received(): OnChainRawNotification { @@ -244,6 +249,7 @@ export function createMockNotificationERC721Received(): OnChainRawNotification { /** * Mocking Utility - create a mock ERC1155 sent notification + * * @returns Mock raw ERC1155 sent notification */ export function createMockNotificationERC1155Sent(): OnChainRawNotification { @@ -288,6 +294,7 @@ export function createMockNotificationERC1155Sent(): OnChainRawNotification { /** * Mocking Utility - create a mock ERC1155 received notification + * * @returns Mock raw ERC1155 received notification */ export function createMockNotificationERC1155Received(): OnChainRawNotification { @@ -332,6 +339,7 @@ export function createMockNotificationERC1155Received(): OnChainRawNotification /** * Mocking Utility - create a mock MetaMask Swaps notification + * * @returns Mock raw MetaMask Swaps notification */ export function createMockNotificationMetaMaskSwapsCompleted(): OnChainRawNotification { @@ -382,6 +390,7 @@ export function createMockNotificationMetaMaskSwapsCompleted(): OnChainRawNotifi /** * Mocking Utility - create a mock RocketPool Stake Completed notification + * * @returns Mock raw RocketPool Stake Completed notification */ export function createMockNotificationRocketPoolStakeCompleted(): OnChainRawNotification { @@ -431,6 +440,7 @@ export function createMockNotificationRocketPoolStakeCompleted(): OnChainRawNoti /** * Mocking Utility - create a mock RocketPool Un-staked notification + * * @returns Mock raw RocketPool Un-staked notification */ export function createMockNotificationRocketPoolUnStakeCompleted(): OnChainRawNotification { @@ -480,6 +490,7 @@ export function createMockNotificationRocketPoolUnStakeCompleted(): OnChainRawNo /** * Mocking Utility - create a mock Lido Stake Completed notification + * * @returns Mock raw Lido Stake Completed notification */ export function createMockNotificationLidoStakeCompleted(): OnChainRawNotification { @@ -529,6 +540,7 @@ export function createMockNotificationLidoStakeCompleted(): OnChainRawNotificati /** * Mocking Utility - create a mock Lido Withdrawal Requested notification + * * @returns Mock raw Lido Withdrawal Requested notification */ export function createMockNotificationLidoWithdrawalRequested(): OnChainRawNotification { @@ -578,6 +590,7 @@ export function createMockNotificationLidoWithdrawalRequested(): OnChainRawNotif /** * Mocking Utility - create a mock Lido Withdrawal Completed notification + * * @returns Mock raw Lido Withdrawal Completed notification */ export function createMockNotificationLidoWithdrawalCompleted(): OnChainRawNotification { @@ -627,6 +640,7 @@ export function createMockNotificationLidoWithdrawalCompleted(): OnChainRawNotif /** * Mocking Utility - create a mock Lido Withdrawal Ready notification + * * @returns Mock raw Lido Withdrawal Ready notification */ export function createMockNotificationLidoReadyToBeWithdrawn(): OnChainRawNotification { @@ -663,6 +677,7 @@ export function createMockNotificationLidoReadyToBeWithdrawn(): OnChainRawNotifi /** * Mocking Utility - create a mock Aave V3 Health Factor notification + * * @returns Mock raw Aave V3 Health Factor notification */ export function createMockNotificationAaveV3HealthFactor(): OnChainRawNotification { @@ -687,6 +702,7 @@ export function createMockNotificationAaveV3HealthFactor(): OnChainRawNotificati /** * Mocking Utility - create a mock ENS Expiration notification + * * @returns Mock raw ENS Expiration notification */ export function createMockNotificationEnsExpiration(): OnChainRawNotification { @@ -712,6 +728,7 @@ export function createMockNotificationEnsExpiration(): OnChainRawNotification { /** * Mocking Utility - create a mock Lido Staking Rewards notification + * * @returns Mock raw Lido Staking Rewards notification */ export function createMockNotificationLidoStakingRewards(): OnChainRawNotification { @@ -739,6 +756,7 @@ export function createMockNotificationLidoStakingRewards(): OnChainRawNotificati /** * Mocking Utility - create a mock Notional Loan Expiration notification + * * @returns Mock raw Notional Loan Expiration notification */ export function createMockNotificationNotionalLoanExpiration(): OnChainRawNotification { @@ -769,6 +787,7 @@ export function createMockNotificationNotionalLoanExpiration(): OnChainRawNotifi /** * Mocking Utility - create a mock Rocketpool Staking Rewards notification + * * @returns Mock raw Rocketpool Staking Rewards notification */ export function createMockNotificationRocketpoolStakingRewards(): OnChainRawNotification { @@ -796,6 +815,7 @@ export function createMockNotificationRocketpoolStakingRewards(): OnChainRawNoti /** * Mocking Utility - create a mock SparkFi Health Factor notification + * * @returns Mock raw SparkFi Health Factor notification */ export function createMockNotificationSparkFiHealthFactor(): OnChainRawNotification { @@ -820,6 +840,7 @@ export function createMockNotificationSparkFiHealthFactor(): OnChainRawNotificat /** * Mocking Utility - creates an array of raw on-chain notifications + * * @returns Array of raw on-chain notifications */ export function createMockRawOnChainNotifications(): OnChainRawNotification[] { diff --git a/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockResponses.ts b/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockResponses.ts index d27a61ef27e..cadbddabe33 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockResponses.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockResponses.ts @@ -1,11 +1,11 @@ +import { createMockFeatureAnnouncementAPIResult } from './mock-feature-announcements'; +import { createMockRawOnChainNotifications } from './mock-raw-notifications'; import { FEATURE_ANNOUNCEMENT_API } from '../services/feature-announcements'; import { NOTIFICATION_API_LIST_ENDPOINT, NOTIFICATION_API_MARK_ALL_AS_READ_ENDPOINT, TRIGGER_API_BATCH_ENDPOINT, } from '../services/onchain-notifications'; -import { createMockFeatureAnnouncementAPIResult } from './mock-feature-announcements'; -import { createMockRawOnChainNotifications } from './mock-raw-notifications'; type MockResponse = { url: string; diff --git a/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/test-utils.ts b/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/test-utils.ts index 6c0983fd234..f97f266894f 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/test-utils.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/test-utils.ts @@ -24,7 +24,7 @@ export const waitFor = async ( assertionFn(); clearInterval(intervalId); resolve(); - } catch (error) { + } catch { if (Date.now() - startTime >= timeoutMs) { clearInterval(intervalId); reject(new Error(`waitFor: timeout reached after ${timeoutMs}ms`)); diff --git a/packages/notification-services-controller/src/NotificationServicesController/constants/notification-schema.ts b/packages/notification-services-controller/src/NotificationServicesController/constants/notification-schema.ts index 89999e3e977..7c85e23fd8a 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/constants/notification-schema.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/constants/notification-schema.ts @@ -1,6 +1,5 @@ import type { Compute } from '../types/type-utils'; -/* eslint-disable @typescript-eslint/naming-convention */ export enum TRIGGER_TYPES { FEATURES_ANNOUNCEMENT = 'features_announcement', METAMASK_SWAP_COMPLETED = 'metamask_swap_completed', diff --git a/packages/notification-services-controller/src/NotificationServicesController/index.ts b/packages/notification-services-controller/src/NotificationServicesController/index.ts index 9aa4f532ab4..70e8ffc6d87 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/index.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/index.ts @@ -4,8 +4,8 @@ const NotificationServicesController = Controller; export { Controller }; export default NotificationServicesController; export * from './NotificationServicesController'; -export * as Types from './types'; -export * from './types'; +export type * as Types from './types'; +export type * from './types'; export * as Processors from './processors'; export * from './processors'; export * as Constants from './constants'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.test.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.test.ts index 8b924be38ca..ab907d2f5d8 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.test.ts @@ -1,9 +1,9 @@ -import { createMockFeatureAnnouncementRaw } from '../__fixtures__/mock-feature-announcements'; -import { TRIGGER_TYPES } from '../constants/notification-schema'; import { isFeatureAnnouncementRead, processFeatureAnnouncement, } from './process-feature-announcement'; +import { createMockFeatureAnnouncementRaw } from '../__fixtures__/mock-feature-announcements'; +import { TRIGGER_TYPES } from '../constants/notification-schema'; describe('process-feature-announcement - isFeatureAnnouncementRead()', () => { const MOCK_NOTIFICATION_ID = 'MOCK_NOTIFICATION_ID'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.test.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.test.ts index 5a5759e8cd5..f681682cf75 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.test.ts @@ -1,8 +1,8 @@ +import { processNotification } from './process-notifications'; import { createMockFeatureAnnouncementRaw } from '../__fixtures__/mock-feature-announcements'; import { createMockNotificationEthSent } from '../__fixtures__/mock-raw-notifications'; import { createMockSnapNotification } from '../__fixtures__/mock-snap-notification'; import type { TRIGGER_TYPES } from '../constants/notification-schema'; -import { processNotification } from './process-notifications'; describe('process-notifications - processNotification()', () => { // More thorough tests are found in the specific process 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 c74510f1e90..e3a29549e8a 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.ts @@ -1,3 +1,9 @@ +import { + isFeatureAnnouncementRead, + processFeatureAnnouncement, +} from './process-feature-announcement'; +import { processOnChainNotification } from './process-onchain-notifications'; +import { processSnapNotification } from './process-snap-notifications'; import { TRIGGER_TYPES } from '../constants/notification-schema'; import type { FeatureAnnouncementRawNotification } from '../types/feature-announcement/feature-announcement'; import type { @@ -6,12 +12,6 @@ import type { } from '../types/notification/notification'; import type { OnChainRawNotification } from '../types/on-chain-notification/on-chain-notification'; import type { RawSnapNotification } from '../types/snaps'; -import { - isFeatureAnnouncementRead, - processFeatureAnnouncement, -} from './process-feature-announcement'; -import { processOnChainNotification } from './process-onchain-notifications'; -import { processSnapNotification } from './process-snap-notifications'; const isOnChainNotification = ( n: RawNotificationUnion, diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-onchain-notifications.test.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-onchain-notifications.test.ts index 707f0d10b2d..989f74a283f 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/processors/process-onchain-notifications.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-onchain-notifications.test.ts @@ -1,3 +1,4 @@ +import { processOnChainNotification } from './process-onchain-notifications'; import { createMockNotificationEthSent, createMockNotificationEthReceived, @@ -16,7 +17,6 @@ import { createMockNotificationLidoReadyToBeWithdrawn, } from '../__fixtures__/mock-raw-notifications'; import type { OnChainRawNotification } from '../types/on-chain-notification/on-chain-notification'; -import { processOnChainNotification } from './process-onchain-notifications'; const rawNotifications = [ createMockNotificationEthSent(), diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.test.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.test.ts index 32bd02f703f..a831c8518a9 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.test.ts @@ -1,6 +1,6 @@ +import { processSnapNotification } from './process-snap-notifications'; import { createMockSnapNotification } from '../__fixtures__'; import { TRIGGER_TYPES } from '../constants'; -import { processSnapNotification } from './process-snap-notifications'; describe('process-snap-notifications - processSnapNotification()', () => { it('processes a Raw Snap Notification to a shared Notification Type', () => { 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 b8665452051..9ac255a412f 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 @@ -1,10 +1,10 @@ -import { createMockFeatureAnnouncementAPIResult } from '../__fixtures__/mock-feature-announcements'; -import { mockFetchFeatureAnnouncementNotifications } from '../__fixtures__/mockServices'; -import { TRIGGER_TYPES } from '../constants/notification-schema'; import { getFeatureAnnouncementNotifications, getFeatureAnnouncementUrl, } from './feature-announcements'; +import { createMockFeatureAnnouncementAPIResult } from '../__fixtures__/mock-feature-announcements'; +import { mockFetchFeatureAnnouncementNotifications } from '../__fixtures__/mockServices'; +import { TRIGGER_TYPES } from '../constants/notification-schema'; // Mocked type for testing, allows overwriting TS to test erroneous values // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/notification-services-controller/src/NotificationServicesController/services/feature-announcements.ts b/packages/notification-services-controller/src/NotificationServicesController/services/feature-announcements.ts index 8ed2963e7c2..67ba5c2bb5f 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/services/feature-announcements.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/services/feature-announcements.ts @@ -35,9 +35,7 @@ type Env = { */ export type ContentfulResult = { includes?: { - // eslint-disable-next-line @typescript-eslint/naming-convention Entry?: Entry[]; - // eslint-disable-next-line @typescript-eslint/naming-convention Asset?: Asset[]; }; items?: TypeFeatureAnnouncement[]; @@ -149,6 +147,7 @@ const fetchFeatureAnnouncementNotifications = async ( /** * Gets Feature Announcement from our services + * * @param env - environment for feature announcements * @param previewToken - the preview token to use if needed * @returns Raw Feature Announcements diff --git a/packages/notification-services-controller/src/NotificationServicesController/services/onchain-notifications.test.ts b/packages/notification-services-controller/src/NotificationServicesController/services/onchain-notifications.test.ts index 0edbdc449c4..5fbaa085459 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/services/onchain-notifications.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/services/onchain-notifications.test.ts @@ -1,3 +1,4 @@ +import * as OnChainNotifications from './onchain-notifications'; import { MOCK_USER_STORAGE_ACCOUNT, MOCK_USER_STORAGE_CHAIN, @@ -12,7 +13,6 @@ import { import { TRIGGER_TYPES } from '../constants/notification-schema'; import type { UserStorage } from '../types/user-storage/user-storage'; import * as Utils from '../utils/utils'; -import * as OnChainNotifications from './onchain-notifications'; const MOCK_STORAGE_KEY = 'MOCK_USER_STORAGE_KEY'; const MOCK_BEARER_TOKEN = 'MOCK_BEARER_TOKEN'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/services/onchain-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/services/onchain-notifications.ts index 35edcb79f10..cdd65b4a302 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/services/onchain-notifications.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/services/onchain-notifications.ts @@ -52,7 +52,6 @@ export async function createOnChainTriggers( token: string; config: { kind: string; - // eslint-disable-next-line @typescript-eslint/naming-convention chain_id: number; address: string; }; @@ -62,7 +61,6 @@ export async function createOnChainTriggers( token: UserStorageController.createSHA256Hash(t.id + storageKey), config: { kind: t.kind, - // eslint-disable-next-line @typescript-eslint/naming-convention chain_id: Number(t.chainId), address: t.address, }, @@ -211,7 +209,6 @@ export async function getOnChainNotifications( bearerToken, NOTIFICATION_API_LIST_ENDPOINT_PAGE_QUERY(page), 'POST', - // eslint-disable-next-line @typescript-eslint/naming-convention { trigger_ids: triggerIds }, ); diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/feature-announcement.ts b/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/feature-announcement.ts index a1d6acb019a..6f461e92148 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/feature-announcement.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/feature-announcement.ts @@ -1,5 +1,5 @@ -import type { TRIGGER_TYPES } from '../../constants/notification-schema'; import type { TypeFeatureAnnouncement } from './type-feature-announcement'; +import type { TRIGGER_TYPES } from '../../constants/notification-schema'; export type FeatureAnnouncementRawNotificationData = Omit< TypeFeatureAnnouncement['fields'], diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/index.ts b/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/index.ts index 6392deec080..a726f8d07fc 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/index.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/index.ts @@ -1,3 +1,3 @@ -export * from './feature-announcement'; -export * from './type-links'; -export * from './type-feature-announcement'; +export type * from './feature-announcement'; +export type * from './type-links'; +export type * from './type-feature-announcement'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/index.ts b/packages/notification-services-controller/src/NotificationServicesController/types/index.ts index af6a940f70e..575c06df258 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/types/index.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/types/index.ts @@ -1,5 +1,5 @@ -export * from './feature-announcement'; -export * from './notification'; -export * from './on-chain-notification'; -export * from './user-storage'; -export * from './snaps/snaps'; +export type * from './feature-announcement'; +export type * from './notification'; +export type * from './on-chain-notification'; +export type * from './user-storage'; +export type * from './snaps/snaps'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/notification/index.ts b/packages/notification-services-controller/src/NotificationServicesController/types/notification/index.ts index d9b217ce3b0..43cd6dff02b 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/types/notification/index.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/types/notification/index.ts @@ -1 +1 @@ -export * from './notification'; +export type * from './notification'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/notification/notification.ts b/packages/notification-services-controller/src/NotificationServicesController/types/notification/notification.ts index 90ec28cb8dd..991e4f627c8 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/types/notification/notification.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/types/notification/notification.ts @@ -28,7 +28,6 @@ export type INotification = Compute< // NFT export type NFT = { - // eslint-disable-next-line @typescript-eslint/naming-convention token_id: string; image: string; collection?: { diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/on-chain-notification/index.ts b/packages/notification-services-controller/src/NotificationServicesController/types/on-chain-notification/index.ts index cc56d6bee41..47a00df68a7 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/types/on-chain-notification/index.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/types/on-chain-notification/index.ts @@ -1 +1 @@ -export * from './on-chain-notification'; +export type * from './on-chain-notification'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/on-chain-notification/on-chain-notification.ts b/packages/notification-services-controller/src/NotificationServicesController/types/on-chain-notification/on-chain-notification.ts index 8bd2d78ef51..844bd95d837 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/types/on-chain-notification/on-chain-notification.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/types/on-chain-notification/on-chain-notification.ts @@ -1,7 +1,6 @@ -/* eslint-disable @typescript-eslint/naming-convention */ +import type { components } from './schema'; import type { TRIGGER_TYPES } from '../../constants/notification-schema'; import type { Compute } from '../type-utils'; -import type { components } from './schema'; export type Data_MetamaskSwapCompleted = components['schemas']['Data_MetamaskSwapCompleted']; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/on-chain-notification/schema.ts b/packages/notification-services-controller/src/NotificationServicesController/types/on-chain-notification/schema.ts index a650b29be64..8f51dcde380 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/types/on-chain-notification/schema.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/types/on-chain-notification/schema.ts @@ -1,4 +1,5 @@ -/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable jsdoc/tag-lines */ +/* eslint-disable jsdoc/check-tag-names */ /** * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/snaps/index.ts b/packages/notification-services-controller/src/NotificationServicesController/types/snaps/index.ts index 648dae45d5f..1e307f3779a 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/types/snaps/index.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/types/snaps/index.ts @@ -1 +1 @@ -export * from './snaps'; +export type * from './snaps'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/user-storage/index.ts b/packages/notification-services-controller/src/NotificationServicesController/types/user-storage/index.ts index 0dce5c8d30c..bf017b8a76c 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/types/user-storage/index.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/types/user-storage/index.ts @@ -1 +1 @@ -export * from './user-storage'; +export type * from './user-storage'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/utils/utils.test.ts b/packages/notification-services-controller/src/NotificationServicesController/utils/utils.test.ts index 9222d897d1b..200754cd159 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/utils/utils.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/utils/utils.test.ts @@ -1,3 +1,4 @@ +import * as Utils from './utils'; import { MOCK_USER_STORAGE_ACCOUNT, MOCK_USER_STORAGE_CHAIN, @@ -10,7 +11,6 @@ import { TRIGGER_TYPES, } from '../constants/notification-schema'; import type { UserStorage } from '../types/user-storage/user-storage'; -import * as Utils from './utils'; describe('metamask-notifications/utils - initializeUserStorage()', () => { it('creates a new user storage object based on the accounts provided', () => { diff --git a/packages/notification-services-controller/src/NotificationServicesController/utils/utils.ts b/packages/notification-services-controller/src/NotificationServicesController/utils/utils.ts index 6e77d17a6e1..22c26d26c0c 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/utils/utils.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/utils/utils.ts @@ -199,7 +199,7 @@ function isAccountEnabled( } const triggerExists = Object.values(accountObject[chain]).some( - (obj) => obj.k === triggerKind, + (obj) => obj.k === (triggerKind as TRIGGER_TYPES), ); if (!triggerExists) { return false; @@ -343,7 +343,7 @@ export function upsertAddressTriggers( // Check if the trigger exists for the chain const existingTrigger = Object.values(userStorage[account][chain]).find( - (obj) => obj.k === trigger, + (obj) => obj.k === (trigger as TRIGGER_TYPES), ); if (!existingTrigger) { diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.test.ts index 2a01d18b7b5..fa60a74139e 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; import log from 'loglevel'; @@ -134,10 +134,7 @@ describe('NotificationServicesPushController', () => { // Test helper functions const buildPushPlatformNotificationsControllerMessenger = () => { - const globalMessenger = new ControllerMessenger< - AllowedActions, - AllowedEvents - >(); + const globalMessenger = new Messenger(); return globalMessenger.getRestricted< 'NotificationServicesPushController', diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.ts b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.ts index 94d39a4210e..daf07e6c64b 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.ts @@ -1,5 +1,5 @@ import type { - RestrictedControllerMessenger, + RestrictedMessenger, ControllerGetStateAction, ControllerStateChangeEvent, StateMetadata, @@ -8,7 +8,6 @@ import { BaseController } from '@metamask/base-controller'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; import log from 'loglevel'; -import type { Types } from '../NotificationServicesController'; import { createRegToken, deleteRegToken } from './services/push/push-web'; import { activatePushNotifications, @@ -17,6 +16,7 @@ import { updateTriggerPushNotifications, } from './services/services'; import type { PushNotificationEnv } from './types'; +import type { Types } from '../NotificationServicesController'; const controllerName = 'NotificationServicesPushController'; @@ -81,14 +81,13 @@ export type Events = export type AllowedEvents = never; -export type NotificationServicesPushControllerMessenger = - RestrictedControllerMessenger< - typeof controllerName, - Actions | AllowedActions, - Events | AllowedEvents, - AllowedActions['type'], - AllowedEvents['type'] - >; +export type NotificationServicesPushControllerMessenger = RestrictedMessenger< + typeof controllerName, + Actions | AllowedActions, + Events | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; export const defaultState: NotificationServicesPushControllerState = { fcmToken: '', @@ -136,8 +135,6 @@ type ControllerConfig = { * It is responsible for registering and unregistering the service worker that listens for push notifications, * managing the FCM token, and communicating with the server to register or unregister the device for push notifications. * Additionally, it provides functionality to update the server with new UUIDs that should trigger push notifications. - * - * @augments {BaseController} */ export default class NotificationServicesPushController extends BaseController< typeof controllerName, @@ -146,9 +143,9 @@ export default class NotificationServicesPushController extends BaseController< > { #pushListenerUnsubscribe: (() => void) | undefined = undefined; - #env: PushNotificationEnv; + readonly #env: PushNotificationEnv; - #config: ControllerConfig; + readonly #config: ControllerConfig; constructor({ messenger, @@ -234,7 +231,7 @@ export default class NotificationServicesPushController extends BaseController< this.#config.onPushNotificationClicked(e, n); }, }); - } catch (e) { + } catch { // Do nothing, we are silently failing if push notification registration fails } } diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockResponse.ts b/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockResponse.ts index 696f622a063..ec3ee7eae9e 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockResponse.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockResponse.ts @@ -9,9 +9,7 @@ type MockResponse = { export const MOCK_REG_TOKEN = 'REG_TOKEN'; export const MOCK_LINKS_RESPONSE: LinksResult = { - // eslint-disable-next-line @typescript-eslint/naming-convention trigger_ids: ['1', '2', '3'], - // eslint-disable-next-line @typescript-eslint/naming-convention registration_tokens: [ { token: 'reg_token_1', platform: 'portfolio' }, { token: 'reg_token_2', platform: 'extension' }, diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/index.ts b/packages/notification-services-controller/src/NotificationServicesPushController/index.ts index c9fc0038277..3c79eebd0b5 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/index.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/index.ts @@ -4,8 +4,8 @@ const NotificationServicesPushController = Controller; export { Controller }; export default NotificationServicesPushController; export * from './NotificationServicesPushController'; -export * as Types from './types'; -export * from './types'; +export type * as Types from './types'; +export type * from './types'; export * as Utils from './utils'; export * from './utils'; export * as Mocks from './__fixtures__'; 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 index 0ffd235dc27..f2d0218d644 100644 --- 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 @@ -3,8 +3,6 @@ import * as FirebaseMessagingModule from 'firebase/messaging'; import * as FirebaseMessagingSWModule from 'firebase/messaging/sw'; import log from 'loglevel'; -import { processNotification } from '../../../NotificationServicesController'; -import { createMockNotificationEthSent } from '../../../NotificationServicesController/__fixtures__'; import * as PushWebModule from './push-web'; import { createRegToken, @@ -12,6 +10,8 @@ import { listenToPushNotificationsReceived, listenToPushNotificationsClicked, } from './push-web'; +import { processNotification } from '../../../NotificationServicesController'; +import { createMockNotificationEthSent } from '../../../NotificationServicesController/__fixtures__'; jest.mock('firebase/app'); jest.mock('firebase/messaging'); 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 index 67b46f68d0c..978bf7cfdee 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/services/push/push-web.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/services/push/push-web.ts @@ -101,13 +101,14 @@ export async function deleteRegToken( await deleteToken(messaging); return true; - } catch (error) { + } 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 diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/services/services.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/services/services.test.ts index 447321e1de1..9ae15d1b679 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/services/services.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/services/services.test.ts @@ -1,10 +1,5 @@ import log from 'loglevel'; -import { - mockEndpointGetPushNotificationLinks, - mockEndpointUpdatePushNotificationLinks, -} from '../__fixtures__/mockServices'; -import type { PushNotificationEnv } from '../types/firebase'; import * as PushWebModule from './push/push-web'; import { activatePushNotifications, @@ -14,6 +9,11 @@ import { updateLinksAPI, updateTriggerPushNotifications, } from './services'; +import { + mockEndpointGetPushNotificationLinks, + mockEndpointUpdatePushNotificationLinks, +} from '../__fixtures__/mockServices'; +import type { PushNotificationEnv } from '../types/firebase'; // Testing util to clean up verbose logs when testing errors const mockErrorLog = () => diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/services/services.ts b/packages/notification-services-controller/src/NotificationServicesPushController/services/services.ts index df07e6acefc..369a96f9d9f 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/services/services.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/services/services.ts @@ -1,13 +1,13 @@ import log from 'loglevel'; -import type { Types } from '../../NotificationServicesController'; -import type { PushNotificationEnv } from '../types'; import * as endpoints from './endpoints'; import type { CreateRegToken, DeleteRegToken } from './push'; import { listenToPushNotificationsClicked, listenToPushNotificationsReceived, } from './push/push-web'; +import type { Types } from '../../NotificationServicesController'; +import type { PushNotificationEnv } from '../types'; export type RegToken = { token: string; @@ -18,9 +18,8 @@ export type RegToken = { * Links API Response Shape */ export type LinksResult = { - // eslint-disable-next-line @typescript-eslint/naming-convention trigger_ids: string[]; - // eslint-disable-next-line @typescript-eslint/naming-convention + registration_tokens: RegToken[]; }; @@ -63,9 +62,7 @@ export async function updateLinksAPI( ): Promise { try { const body: LinksResult = { - // eslint-disable-next-line @typescript-eslint/naming-convention trigger_ids: triggers, - // eslint-disable-next-line @typescript-eslint/naming-convention registration_tokens: regTokens, }; const response = await fetch(endpoints.REGISTRATION_TOKENS_ENDPOINT, { diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/types/index.ts b/packages/notification-services-controller/src/NotificationServicesPushController/types/index.ts index 5588511bf30..76a9e7e5d34 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/types/index.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/types/index.ts @@ -1 +1 @@ -export * from './firebase'; +export type * from './firebase'; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-message.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-message.test.ts index 21b39e97806..dd2afe35702 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-message.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-message.test.ts @@ -1,3 +1,5 @@ +import type { TranslationKeys } from './get-notification-message'; +import { createOnChainPushNotificationMessage } from './get-notification-message'; import { Processors } from '../../NotificationServicesController'; import { createMockNotificationERC1155Received, @@ -16,8 +18,6 @@ import { createMockNotificationRocketPoolStakeCompleted, createMockNotificationRocketPoolUnStakeCompleted, } from '../../NotificationServicesController/__fixtures__'; -import type { TranslationKeys } from './get-notification-message'; -import { createOnChainPushNotificationMessage } from './get-notification-message'; const mockTranslations: TranslationKeys = { pushPlatformNotificationsFundsSentTitle: () => 'Funds sent', diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-message.ts b/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-message.ts index 38760528139..390c8f4270a 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-message.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-message.ts @@ -1,7 +1,6 @@ -/* eslint-disable @typescript-eslint/naming-convention */ +import { getAmount, formatAmount } from './get-notification-data'; import type { Types } from '../../NotificationServicesController'; import { Constants } from '../../NotificationServicesController'; -import { getAmount, formatAmount } from './get-notification-data'; export type TranslationKeys = { pushPlatformNotificationsFundsSentTitle: () => string; @@ -288,7 +287,7 @@ export function createOnChainPushNotificationMessage( notificationMessage?.getDescription?.(n as any) ?? notificationMessage.defaultDescription ?? null; - } catch (e) { + } catch { description = notificationMessage.defaultDescription ?? null; } diff --git a/packages/permission-controller/ARCHITECTURE.md b/packages/permission-controller/ARCHITECTURE.md index bce0a401778..83119eed959 100644 --- a/packages/permission-controller/ARCHITECTURE.md +++ b/packages/permission-controller/ARCHITECTURE.md @@ -318,7 +318,7 @@ const permissionSpecifications = { const permissionController = new PermissionController({ caveatSpecifications, - messenger: controllerMessenger, // assume this was given + messenger: permissionControllerMessenger, // assume this was given permissionSpecifications, unrestrictedMethods: ['wallet_unrestrictedMethod'], }); diff --git a/packages/permission-controller/CHANGELOG.md b/packages/permission-controller/CHANGELOG.md index d50033d54e6..aaff529195a 100644 --- a/packages/permission-controller/CHANGELOG.md +++ b/packages/permission-controller/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [11.0.6] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.5` to `^11.5.0` ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/json-rpc-engine` from `^10.0.2` to `^10.0.3` ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + ## [11.0.5] ### Changed @@ -19,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `nanoid` from `^3.1.31` to `^3.3.8` ([#5073](https://github.com/MetaMask/core/pull/5073)) - Bump `@metamask/utils` from `^10.0.0` to `^11.0.1` ([#5080](https://github.com/MetaMask/core/pull/5080)) - Bump `@metamask/rpc-errors` from `^7.0.0` to `^7.0.2` ([#5080](https://github.com/MetaMask/core/pull/5080)) -- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) +- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.1` ([#5079](https://github.com/MetaMask/core/pull/5079)), ([#5135](https://github.com/MetaMask/core/pull/5135)) ## [11.0.4] @@ -321,7 +330,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/permission-controller@11.0.5...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@11.0.6...HEAD +[11.0.6]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@11.0.5...@metamask/permission-controller@11.0.6 [11.0.5]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@11.0.4...@metamask/permission-controller@11.0.5 [11.0.4]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@11.0.3...@metamask/permission-controller@11.0.4 [11.0.3]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@11.0.2...@metamask/permission-controller@11.0.3 diff --git a/packages/permission-controller/package.json b/packages/permission-controller/package.json index 613b88c4b56..f4a09c245f3 100644 --- a/packages/permission-controller/package.json +++ b/packages/permission-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/permission-controller", - "version": "11.0.5", + "version": "11.0.6", "description": "Mediates access to JSON-RPC methods, used to interact with pieces of the MetaMask stack, via middleware for json-rpc-engine", "keywords": [ "MetaMask", @@ -47,18 +47,18 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/controller-utils": "^11.4.5", - "@metamask/json-rpc-engine": "^10.0.2", + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", + "@metamask/json-rpc-engine": "^10.0.3", "@metamask/rpc-errors": "^7.0.2", - "@metamask/utils": "^11.0.1", + "@metamask/utils": "^11.1.0", "@types/deep-freeze-strict": "^1.1.0", "deep-freeze-strict": "^1.1.1", "immer": "^9.0.6", "nanoid": "^3.3.8" }, "devDependencies": { - "@metamask/approval-controller": "^7.1.2", + "@metamask/approval-controller": "^7.1.3", "@metamask/auto-changelog": "^3.4.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", diff --git a/packages/permission-controller/src/PermissionController.test.ts b/packages/permission-controller/src/PermissionController.test.ts index fcb8ff73ae7..1d9ae757862 100644 --- a/packages/permission-controller/src/PermissionController.test.ts +++ b/packages/permission-controller/src/PermissionController.test.ts @@ -4,7 +4,7 @@ import type { HasApprovalRequest, RejectRequest as RejectApprovalRequest, } from '@metamask/approval-controller'; -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { isPlainObject } from '@metamask/controller-utils'; import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import type { Json, JsonRpcRequest } from '@metamask/utils'; @@ -566,19 +566,19 @@ type AddPermissionRequestParams = { type AddPermissionRequestArgs = [string, AddPermissionRequestParams]; /** - * Gets a unrestricted controller messenger. Used for tests. + * Gets an unrestricted messenger. Used for tests. * * @returns The unrestricted messenger. */ function getUnrestrictedMessenger() { - return new ControllerMessenger< + return new Messenger< PermissionControllerActions | AllowedActions, PermissionControllerEvents >(); } /** - * Gets a restricted controller messenger. + * Gets a restricted messenger. * Used as a default in {@link getPermissionControllerOptions}. * * @param messenger - Optional parameter to pass in a messenger diff --git a/packages/permission-controller/src/PermissionController.ts b/packages/permission-controller/src/PermissionController.ts index 27cf026d018..b19c6ebd655 100644 --- a/packages/permission-controller/src/PermissionController.ts +++ b/packages/permission-controller/src/PermissionController.ts @@ -7,7 +7,7 @@ import type { } from '@metamask/approval-controller'; import type { StateMetadata, - RestrictedControllerMessenger, + RestrictedMessenger, ActionConstraint, EventConstraint, ControllerGetStateAction, @@ -356,7 +356,7 @@ export type GetEndowments = { }; /** - * The {@link ControllerMessenger} actions of the {@link PermissionController}. + * The {@link Messenger} actions of the {@link PermissionController}. */ export type PermissionControllerActions = | ClearPermissions @@ -384,7 +384,7 @@ export type PermissionControllerStateChange = ControllerStateChangeEvent< >; /** - * The {@link ControllerMessenger} events of the {@link PermissionController}. + * The {@link Messenger} events of the {@link PermissionController}. * * The permission controller only emits its generic state change events. * Consumers should use selector subscriptions to subscribe to relevant @@ -393,7 +393,7 @@ export type PermissionControllerStateChange = ControllerStateChangeEvent< export type PermissionControllerEvents = PermissionControllerStateChange; /** - * The external {@link ControllerMessenger} actions available to the + * The external {@link Messenger} actions available to the * {@link PermissionController}. */ type AllowedActions = @@ -406,7 +406,7 @@ type AllowedActions = /** * The messenger of the {@link PermissionController}. */ -export type PermissionControllerMessenger = RestrictedControllerMessenger< +export type PermissionControllerMessenger = RestrictedMessenger< typeof controllerName, PermissionControllerActions | AllowedActions, PermissionControllerEvents, @@ -417,7 +417,7 @@ export type PermissionControllerMessenger = RestrictedControllerMessenger< export type SideEffectMessenger< Actions extends ActionConstraint, Events extends EventConstraint, -> = RestrictedControllerMessenger< +> = RestrictedMessenger< typeof controllerName, Actions | AllowedActions, Events, @@ -565,7 +565,7 @@ export type PermissionControllerOptions< * document for details. * * Assumes the existence of an {@link ApprovalController} reachable via the - * {@link ControllerMessenger}. + * {@link Messenger}. * * @template ControllerPermissionSpecification - A union of the types of all * permission specifications available to the controller. Any referenced caveats @@ -629,7 +629,7 @@ export class PermissionController< * {@link PermissionSpecificationMap} and the README for more details. * @param options.unrestrictedMethods - The callable names of all JSON-RPC * methods ignored by the new controller. - * @param options.messenger - The controller messenger. See + * @param options.messenger - The messenger. See * {@link BaseController} for more information. * @param options.state - Existing state to hydrate the controller with at * initialization. diff --git a/packages/permission-controller/src/SubjectMetadataController.test.ts b/packages/permission-controller/src/SubjectMetadataController.test.ts index cfc861111b8..93e8fbb48e5 100644 --- a/packages/permission-controller/src/SubjectMetadataController.test.ts +++ b/packages/permission-controller/src/SubjectMetadataController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import type { Json } from '@metamask/utils'; import type { HasPermissions } from './PermissionController'; @@ -14,27 +14,24 @@ import { const controllerName = 'SubjectMetadataController'; /** - * Utility function for creating a controller messenger. + * Utility function for creating a messenger. * * @returns A tuple containing the messenger and a spy for the "hasPermission" action handler */ function getSubjectMetadataControllerMessenger() { - const controllerMessenger = new ControllerMessenger< + const messenger = new Messenger< SubjectMetadataControllerActions | HasPermissions, SubjectMetadataControllerEvents >(); const hasPermissionsSpy = jest.fn(); - controllerMessenger.registerActionHandler( + messenger.registerActionHandler( 'PermissionController:hasPermissions', hasPermissionsSpy, ); return [ - controllerMessenger.getRestricted< - typeof controllerName, - HasPermissions['type'] - >({ + messenger.getRestricted({ name: controllerName, allowedActions: ['PermissionController:hasPermissions'], allowedEvents: [], diff --git a/packages/permission-controller/src/SubjectMetadataController.ts b/packages/permission-controller/src/SubjectMetadataController.ts index be67943e4e9..38bf8a4a402 100644 --- a/packages/permission-controller/src/SubjectMetadataController.ts +++ b/packages/permission-controller/src/SubjectMetadataController.ts @@ -1,7 +1,7 @@ import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { Json } from '@metamask/utils'; @@ -84,7 +84,7 @@ export type SubjectMetadataControllerEvents = SubjectMetadataStateChange; type AllowedActions = HasPermissions; -export type SubjectMetadataControllerMessenger = RestrictedControllerMessenger< +export type SubjectMetadataControllerMessenger = RestrictedMessenger< typeof controllerName, SubjectMetadataControllerActions | AllowedActions, SubjectMetadataControllerEvents, diff --git a/packages/permission-log-controller/CHANGELOG.md b/packages/permission-log-controller/CHANGELOG.md index f271fb58477..059fe4c7490 100644 --- a/packages/permission-log-controller/CHANGELOG.md +++ b/packages/permission-log-controller/CHANGELOG.md @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.3] + ### Changed -- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) +- Bump `@metamask/base-controller` from `^7.0.0` to `^8.0.0` ([#5079](https://github.com/MetaMask/core/pull/5079)), ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/json-rpc-engine` from `^10.0.1` to `^10.0.3` ([#5082](https://github.com/MetaMask/core/pull/5082)), ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^10.0.0` to `^11.1.0` ([#5080](https://github.com/MetaMask/core/pull/5080)), ([#5223](https://github.com/MetaMask/core/pull/5223)) +- Bump `nanoid` from `^3.1.31` to `^3.3.8` ([#5073](https://github.com/MetaMask/core/pull/5073)) ## [3.0.2] @@ -88,7 +93,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/permission-log-controller@3.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@3.0.3...HEAD +[3.0.3]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@3.0.2...@metamask/permission-log-controller@3.0.3 [3.0.2]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@3.0.1...@metamask/permission-log-controller@3.0.2 [3.0.1]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@3.0.0...@metamask/permission-log-controller@3.0.1 [3.0.0]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@2.0.2...@metamask/permission-log-controller@3.0.0 diff --git a/packages/permission-log-controller/package.json b/packages/permission-log-controller/package.json index c127b4e6f78..e59bec33ae1 100644 --- a/packages/permission-log-controller/package.json +++ b/packages/permission-log-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/permission-log-controller", - "version": "3.0.2", + "version": "3.0.3", "description": "Controller with middleware for logging requests and responses to restricted and permissions-related methods", "keywords": [ "MetaMask", @@ -47,9 +47,9 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/json-rpc-engine": "^10.0.2", - "@metamask/utils": "^11.0.1" + "@metamask/base-controller": "^8.0.0", + "@metamask/json-rpc-engine": "^10.0.3", + "@metamask/utils": "^11.1.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", diff --git a/packages/permission-log-controller/src/PermissionLogController.ts b/packages/permission-log-controller/src/PermissionLogController.ts index 7600b9585e8..6f5db5424a2 100644 --- a/packages/permission-log-controller/src/PermissionLogController.ts +++ b/packages/permission-log-controller/src/PermissionLogController.ts @@ -1,6 +1,6 @@ import { BaseController, - type RestrictedControllerMessenger, + type RestrictedMessenger, } from '@metamask/base-controller'; import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; import { @@ -70,7 +70,7 @@ export type PermissionLogControllerOptions = { messenger: PermissionLogControllerMessenger; }; -export type PermissionLogControllerMessenger = RestrictedControllerMessenger< +export type PermissionLogControllerMessenger = RestrictedMessenger< typeof name, never, never, diff --git a/packages/permission-log-controller/tests/PermissionLogController.test.ts b/packages/permission-log-controller/tests/PermissionLogController.test.ts index a0918eb4748..9ca23324042 100644 --- a/packages/permission-log-controller/tests/PermissionLogController.test.ts +++ b/packages/permission-log-controller/tests/PermissionLogController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import type { JsonRpcEngineReturnHandler, JsonRpcEngineNextCallback, @@ -41,7 +41,7 @@ const initController = ({ restrictedMethods: Set; state?: Partial; }): PermissionLogController => { - const messenger = new ControllerMessenger().getRestricted({ + const messenger = new Messenger().getRestricted({ name, allowedActions: [], allowedEvents: [], diff --git a/packages/phishing-controller/CHANGELOG.md b/packages/phishing-controller/CHANGELOG.md index 85136854cad..143ce19bb30 100644 --- a/packages/phishing-controller/CHANGELOG.md +++ b/packages/phishing-controller/CHANGELOG.md @@ -7,9 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [12.3.2] + ### Changed -- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) +- Bump `@metamask/base-controller` from `^7.0.2` to `^8.0.0` ([#5079](https://github.com/MetaMask/core/pull/5079)), ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5272](https://github.com/MetaMask/core/pull/5272)) ## [12.3.1] @@ -321,7 +324,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@12.3.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.3.2...HEAD +[12.3.2]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.3.1...@metamask/phishing-controller@12.3.2 [12.3.1]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.3.0...@metamask/phishing-controller@12.3.1 [12.3.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.2.0...@metamask/phishing-controller@12.3.0 [12.2.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.1.0...@metamask/phishing-controller@12.2.0 diff --git a/packages/phishing-controller/package.json b/packages/phishing-controller/package.json index ed3c6597fae..6490230fdb2 100644 --- a/packages/phishing-controller/package.json +++ b/packages/phishing-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/phishing-controller", - "version": "12.3.1", + "version": "12.3.2", "description": "Maintains a periodically updated list of approved and unapproved website origins", "keywords": [ "MetaMask", @@ -47,8 +47,8 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/controller-utils": "^11.4.5", + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", "@noble/hashes": "^1.4.0", "@types/punycode": "^2.1.0", "ethereum-cryptography": "^2.1.2", diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index 810f7a179a8..35fdfb27014 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { strict as assert } from 'assert'; import nock from 'nock'; import * as sinon from 'sinon'; @@ -21,23 +21,18 @@ import { getHostnameFromUrl } from './utils'; const controllerName = 'PhishingController'; /** - * Constructs a restricted controller messenger. + * Constructs a restricted messenger. * - * @returns A restricted controller messenger. + * @returns A restricted messenger. */ function getRestrictedMessenger() { - const controllerMessenger = new ControllerMessenger< - PhishingControllerActions, - never - >(); + const messenger = new Messenger(); - const messenger = controllerMessenger.getRestricted({ + return messenger.getRestricted({ name: controllerName, allowedActions: [], allowedEvents: [], }); - - return messenger; } /** diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index cdf8eaab235..d62ebaac87c 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -1,7 +1,7 @@ import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import { safelyExecute } from '@metamask/controller-utils'; @@ -268,7 +268,7 @@ export type PhishingControllerStateChangeEvent = ControllerStateChangeEvent< export type PhishingControllerEvents = PhishingControllerStateChangeEvent; -export type PhishingControllerMessenger = RestrictedControllerMessenger< +export type PhishingControllerMessenger = RestrictedMessenger< typeof controllerName, PhishingControllerActions, PhishingControllerEvents, diff --git a/packages/polling-controller/CHANGELOG.md b/packages/polling-controller/CHANGELOG.md index f7a343b3e8d..b10a6cc9b90 100644 --- a/packages/polling-controller/CHANGELOG.md +++ b/packages/polling-controller/CHANGELOG.md @@ -7,9 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [12.0.3] + ### Changed -- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) +- Bump `@metamask/base-controller` from `^7.0.2` to `^8.0.0` ([#5079](https://github.com/MetaMask/core/pull/5079)), ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^10.0.0` to `^11.1.0` ([#5080](https://github.com/MetaMask/core/pull/5080)), ([#5223](https://github.com/MetaMask/core/pull/5223)) + +### Removed + +- **BREAKING:** Remove `BlockTrackerPollingControllerV1`, `StaticIntervalPollingControllerV1` ([#5018](https://github.com/MetaMask/core/pull/5018/)) ## [12.0.2] @@ -225,7 +233,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/polling-controller@12.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/polling-controller@12.0.3...HEAD +[12.0.3]: https://github.com/MetaMask/core/compare/@metamask/polling-controller@12.0.2...@metamask/polling-controller@12.0.3 [12.0.2]: https://github.com/MetaMask/core/compare/@metamask/polling-controller@12.0.1...@metamask/polling-controller@12.0.2 [12.0.1]: https://github.com/MetaMask/core/compare/@metamask/polling-controller@12.0.0...@metamask/polling-controller@12.0.1 [12.0.0]: https://github.com/MetaMask/core/compare/@metamask/polling-controller@11.0.0...@metamask/polling-controller@12.0.0 diff --git a/packages/polling-controller/package.json b/packages/polling-controller/package.json index dddd08b1931..ebf794c0603 100644 --- a/packages/polling-controller/package.json +++ b/packages/polling-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/polling-controller", - "version": "12.0.2", + "version": "12.0.3", "description": "Polling Controller is the base for controllers that polling by networkClientId", "keywords": [ "MetaMask", @@ -47,16 +47,16 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/controller-utils": "^11.4.5", - "@metamask/utils": "^11.0.1", + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", + "@metamask/utils": "^11.1.0", "@types/uuid": "^8.3.0", "fast-json-stable-stringify": "^2.1.0", "uuid": "^8.3.2" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^22.1.1", + "@metamask/network-controller": "^22.2.1", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/polling-controller/src/BlockTrackerPollingController.test.ts b/packages/polling-controller/src/BlockTrackerPollingController.test.ts index 90192e50493..d5c8615a3ff 100644 --- a/packages/polling-controller/src/BlockTrackerPollingController.test.ts +++ b/packages/polling-controller/src/BlockTrackerPollingController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import type { NetworkClient } from '@metamask/network-controller'; import EventEmitter from 'events'; import { useFakeTimers } from 'sinon'; @@ -53,7 +53,7 @@ describe('BlockTrackerPollingController', () => { beforeEach(() => { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockMessenger = new ControllerMessenger(); + mockMessenger = new Messenger(); controller = new ChildBlockTrackerPollingController({ messenger: mockMessenger, metadata: {}, diff --git a/packages/polling-controller/src/BlockTrackerPollingController.ts b/packages/polling-controller/src/BlockTrackerPollingController.ts index cb97c5511ef..f7221768f90 100644 --- a/packages/polling-controller/src/BlockTrackerPollingController.ts +++ b/packages/polling-controller/src/BlockTrackerPollingController.ts @@ -1,4 +1,4 @@ -import { BaseController, BaseControllerV1 } from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; import type { NetworkClientId, NetworkClient, @@ -98,10 +98,3 @@ export const BlockTrackerPollingController = < BlockTrackerPollingControllerMixin( BaseController, ); - -export const BlockTrackerPollingControllerV1 = < - PollingInput extends BlockTrackerPollingInput, ->() => - BlockTrackerPollingControllerMixin( - BaseControllerV1, - ); diff --git a/packages/polling-controller/src/StaticIntervalPollingController.test.ts b/packages/polling-controller/src/StaticIntervalPollingController.test.ts index b166b90a795..797f431bc88 100644 --- a/packages/polling-controller/src/StaticIntervalPollingController.test.ts +++ b/packages/polling-controller/src/StaticIntervalPollingController.test.ts @@ -1,9 +1,9 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { createDeferredPromise } from '@metamask/utils'; import { useFakeTimers } from 'sinon'; -import { advanceTime } from '../../../tests/helpers'; import { StaticIntervalPollingController } from './StaticIntervalPollingController'; +import { advanceTime } from '../../../tests/helpers'; const TICK_TIME = 5; @@ -46,7 +46,7 @@ describe('StaticIntervalPollingController', () => { beforeEach(() => { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockMessenger = new ControllerMessenger(); + mockMessenger = new Messenger(); controller = new ChildBlockTrackerPollingController({ messenger: mockMessenger, metadata: {}, diff --git a/packages/polling-controller/src/StaticIntervalPollingController.ts b/packages/polling-controller/src/StaticIntervalPollingController.ts index 53493601fa9..5076dfcffdf 100644 --- a/packages/polling-controller/src/StaticIntervalPollingController.ts +++ b/packages/polling-controller/src/StaticIntervalPollingController.ts @@ -1,4 +1,4 @@ -import { BaseController, BaseControllerV1 } from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; import type { Json } from '@metamask/utils'; import { @@ -89,10 +89,3 @@ export const StaticIntervalPollingController = () => StaticIntervalPollingControllerMixin( BaseController, ); - -export const StaticIntervalPollingControllerV1 = < - PollingInput extends Json, ->() => - StaticIntervalPollingControllerMixin( - BaseControllerV1, - ); diff --git a/packages/polling-controller/src/index.ts b/packages/polling-controller/src/index.ts index 90e7ea8cde8..ba1758c443b 100644 --- a/packages/polling-controller/src/index.ts +++ b/packages/polling-controller/src/index.ts @@ -1,13 +1,11 @@ export { BlockTrackerPollingControllerOnly, BlockTrackerPollingController, - BlockTrackerPollingControllerV1, } from './BlockTrackerPollingController'; export { StaticIntervalPollingControllerOnly, StaticIntervalPollingController, - StaticIntervalPollingControllerV1, } from './StaticIntervalPollingController'; export type { IPollingController } from './types'; diff --git a/packages/preferences-controller/CHANGELOG.md b/packages/preferences-controller/CHANGELOG.md index b97f3059ab5..787b42afa99 100644 --- a/packages/preferences-controller/CHANGELOG.md +++ b/packages/preferences-controller/CHANGELOG.md @@ -7,9 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [15.0.2] + ### Changed -- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) +- Bump `@metamask/base-controller` from `^7.0.2` to `^8.0.0` ([#5079](https://github.com/MetaMask/core/pull/5079)), ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5272](https://github.com/MetaMask/core/pull/5272)) ## [15.0.1] @@ -337,7 +340,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/preferences-controller@15.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/preferences-controller@15.0.2...HEAD +[15.0.2]: https://github.com/MetaMask/core/compare/@metamask/preferences-controller@15.0.1...@metamask/preferences-controller@15.0.2 [15.0.1]: https://github.com/MetaMask/core/compare/@metamask/preferences-controller@15.0.0...@metamask/preferences-controller@15.0.1 [15.0.0]: https://github.com/MetaMask/core/compare/@metamask/preferences-controller@14.0.0...@metamask/preferences-controller@15.0.0 [14.0.0]: https://github.com/MetaMask/core/compare/@metamask/preferences-controller@13.3.0...@metamask/preferences-controller@14.0.0 diff --git a/packages/preferences-controller/package.json b/packages/preferences-controller/package.json index e2f76246c1d..2bcf9830665 100644 --- a/packages/preferences-controller/package.json +++ b/packages/preferences-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/preferences-controller", - "version": "15.0.1", + "version": "15.0.2", "description": "Manages user-configurable settings for MetaMask", "keywords": [ "MetaMask", @@ -47,12 +47,12 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/controller-utils": "^11.4.5" + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^19.0.4", + "@metamask/keyring-controller": "^19.1.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/preferences-controller/src/PreferencesController.test.ts b/packages/preferences-controller/src/PreferencesController.test.ts index e36a3eeb8bf..2b22a708e3c 100644 --- a/packages/preferences-controller/src/PreferencesController.test.ts +++ b/packages/preferences-controller/src/PreferencesController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { getDefaultKeyringState } from '@metamask/keyring-controller'; import { cloneDeep } from 'lodash'; @@ -48,7 +48,7 @@ describe('PreferencesController', () => { describe('KeyringController:stateChange', () => { it('should update identities state to reflect new keyring accounts', () => { - const messenger = getControllerMessenger(); + const messenger = getMessenger(); const controller = setupPreferencesController({ options: { state: { @@ -89,7 +89,7 @@ describe('PreferencesController', () => { }); it('should update identities state to reflect removed keyring accounts', () => { - const messenger = getControllerMessenger(); + const messenger = getMessenger(); const controller = setupPreferencesController({ options: { state: { @@ -119,7 +119,7 @@ describe('PreferencesController', () => { }); it('should update selected address to first identity if the selected address was removed', () => { - const messenger = getControllerMessenger(); + const messenger = getMessenger(); const controller = setupPreferencesController({ options: { state: { @@ -152,7 +152,7 @@ describe('PreferencesController', () => { '0x01': { address: '0x01', importTime: 2, name: 'Account 2' }, '0x02': { address: '0x02', importTime: 3, name: 'Account 3' }, }; - const messenger = getControllerMessenger(); + const messenger = getMessenger(); const controller = setupPreferencesController({ options: { state: { @@ -181,7 +181,7 @@ describe('PreferencesController', () => { '0x01': { address: '0x01', importTime: 2, name: 'Account 2' }, '0x02': { address: '0x02', importTime: 3, name: 'Account 3' }, }; - const messenger = getControllerMessenger(); + const messenger = getMessenger(); const controller = setupPreferencesController({ options: { state: { @@ -212,7 +212,7 @@ describe('PreferencesController', () => { '0x01': { address: '0x01', importTime: 2, name: 'Account 2' }, '0x02': { address: '0x02', importTime: 3, name: 'Account 3' }, }; - const messenger = getControllerMessenger(); + const messenger = getMessenger(); const controller = setupPreferencesController({ options: { state: { @@ -244,7 +244,7 @@ describe('PreferencesController', () => { '0x01': { address: '0x01', importTime: 2, name: 'Account 2' }, '0x02': { address: '0x02', importTime: 3, name: 'Account 3' }, }; - const messenger = getControllerMessenger(); + const messenger = getMessenger(); const controller = setupPreferencesController({ options: { state: { @@ -478,18 +478,18 @@ describe('PreferencesController', () => { }); /** - * Construct a controller messenger for use in PreferencesController tests. + * Construct a messenger for use in PreferencesController tests. * * This is a utility function that saves us from manually entering the correct - * type parameters for the ControllerMessenger each time we construct it. + * type parameters for the Messenger each time we construct it. * - * @returns A controller messenger + * @returns A messenger */ -function getControllerMessenger(): ControllerMessenger< +function getMessenger(): Messenger< PreferencesControllerActions, PreferencesControllerEvents | AllowedEvents > { - return new ControllerMessenger< + return new Messenger< PreferencesControllerActions, PreferencesControllerEvents | AllowedEvents >(); @@ -500,21 +500,20 @@ function getControllerMessenger(): ControllerMessenger< * * @param args - Arguments * @param args.options - PreferencesController options. - * @param args.messenger - A controller messenger. + * @param args.messenger - A messenger. * @returns A PreferencesController instance. */ function setupPreferencesController({ options = {}, - messenger, + messenger = getMessenger(), }: { options?: Partial[0]>; - messenger?: ControllerMessenger< + messenger?: Messenger< PreferencesControllerActions, PreferencesControllerEvents | AllowedEvents >; } = {}) { - const controllerMessenger = messenger ?? getControllerMessenger(); - const preferencesControllerMessenger = controllerMessenger.getRestricted< + const preferencesControllerMessenger = messenger.getRestricted< 'PreferencesController', never, AllowedEvents['type'] diff --git a/packages/preferences-controller/src/PreferencesController.ts b/packages/preferences-controller/src/PreferencesController.ts index 28af915a878..c61800c052a 100644 --- a/packages/preferences-controller/src/PreferencesController.ts +++ b/packages/preferences-controller/src/PreferencesController.ts @@ -2,7 +2,7 @@ import { BaseController, type ControllerStateChangeEvent, type ControllerGetStateAction, - type RestrictedControllerMessenger, + type RestrictedMessenger, } from '@metamask/base-controller'; import { toChecksumHexAddress } from '@metamask/controller-utils'; import type { @@ -174,7 +174,7 @@ export type PreferencesControllerEvents = PreferencesControllerStateChangeEvent; export type AllowedEvents = KeyringControllerStateChangeEvent; -export type PreferencesControllerMessenger = RestrictedControllerMessenger< +export type PreferencesControllerMessenger = RestrictedMessenger< typeof name, PreferencesControllerActions, PreferencesControllerEvents | AllowedEvents, diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 840ade37d05..869971917d9 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [8.0.0] + +### Added + +- Add `perform{BatchSetStorage,DeleteStorage,BatchDeleteStorage}` as messenger actions ([#5311](https://github.com/MetaMask/core/pull/5311)) +- Add optional `validateAgainstSchema` option when creating user storage entry paths ([#5326](https://github.com/MetaMask/core/pull/5326)) + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^23.0.0` to `^24.0.0` ([#5318](https://github.com/MetaMask/core/pull/5318)) +- Change `maxNumberOfAccountsToAdd` default value from `100` to `Infinity` ([#5322](https://github.com/MetaMask/core/pull/5322)) + +### Removed + +- Removed unused events from `UserStorageController` ([#5324](https://github.com/MetaMask/core/pull/5324)) + +## [7.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/keyring-controller` from `^19.0.6` to `^19.0.7` ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/network-controller` from `^22.2.0` to `^22.2.1` ([#5305](https://github.com/MetaMask/core/pull/5305)) + +## [7.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^22.0.0` to `^23.0.0` ([#5292](https://github.com/MetaMask/core/pull/5292)) + +## [6.0.0] + +### Changed + +- Improve logic & dependencies between profile sync, auth, user storage & notifications ([#5275](https://github.com/MetaMask/core/pull/5275)) +- Mark `@metamask/snaps-controllers` peer dependency bump as breaking in CHANGELOG ([#5267](https://github.com/MetaMask/core/pull/5267)) +- Fix eslint warnings & errors ([#5261](https://github.com/MetaMask/core/pull/5261)) +- Rename `ControllerMessenger` to `Messenger` ([#5244](https://github.com/MetaMask/core/pull/5244)) +- Bump snaps-sdk to v6.16.0 ([#5220](https://github.com/MetaMask/core/pull/5220)) +- **BREAKING:** Bump `@metamask/snaps-controllers` peer dependency from `^9.10.0` to `^9.19.0` ([#5265](https://github.com/MetaMask/core/pull/5265)) +- Bump `@metamask/snaps-sdk` from `^6.16.0` to `^6.17.1` ([#5265](https://github.com/MetaMask/core/pull/5265)) +- Bump `@metamask/snaps-utils` from `^8.9.0` to `^8.10.0` ([#5265](https://github.com/MetaMask/core/pull/5265)) +- Bump `@metamask/keyring-api"` from `^16.1.0` to `^17.0.0` ([#5280](https://github.com/MetaMask/core/pull/5280)) + +### Removed + +- **BREAKING:** Remove metametrics dependencies in UserStorageController ([#5278](https://github.com/MetaMask/core/pull/5278)) + +## [5.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^21.0.0` to `^22.0.0` ([#5218](https://github.com/MetaMask/core/pull/5218)) +- Bump `@metamask/keyring-api` from `^14.0.0` to `^16.1.0` ([#5190](https://github.com/MetaMask/core/pull/5190)), ([#5208](https://github.com/MetaMask/core/pull/5208)) + ## [4.1.1] ### Changed @@ -428,7 +483,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@4.1.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@8.0.0...HEAD +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@7.0.1...@metamask/profile-sync-controller@8.0.0 +[7.0.1]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@7.0.0...@metamask/profile-sync-controller@7.0.1 +[7.0.0]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@6.0.0...@metamask/profile-sync-controller@7.0.0 +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@5.0.0...@metamask/profile-sync-controller@6.0.0 +[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@4.1.1...@metamask/profile-sync-controller@5.0.0 [4.1.1]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@4.1.0...@metamask/profile-sync-controller@4.1.1 [4.1.0]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@4.0.1...@metamask/profile-sync-controller@4.1.0 [4.0.1]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@4.0.0...@metamask/profile-sync-controller@4.0.1 diff --git a/packages/profile-sync-controller/jest.environment.js b/packages/profile-sync-controller/jest.environment.js index 46710c45482..42e6f334993 100644 --- a/packages/profile-sync-controller/jest.environment.js +++ b/packages/profile-sync-controller/jest.environment.js @@ -1,3 +1,5 @@ +/* eslint-disable n/prefer-global/text-encoder */ +/* eslint-disable n/prefer-global/text-decoder */ const JSDOMEnvironment = require('jest-environment-jsdom'); /** @@ -10,6 +12,7 @@ class CustomTestEnvironment extends JSDOMEnvironment { async setup() { await super.setup(); + // eslint-disable-next-line no-shadow const { TextEncoder, TextDecoder } = require('util'); this.global.TextEncoder = TextEncoder; this.global.TextDecoder = TextDecoder; @@ -17,6 +20,7 @@ class CustomTestEnvironment extends JSDOMEnvironment { this.global.Uint8Array = Uint8Array; if (typeof this.global.crypto === 'undefined') { + // eslint-disable-next-line n/no-unsupported-features/node-builtins this.global.crypto = require('crypto').webcrypto; } } diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index 792ff444c71..4d528d68989 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/profile-sync-controller", - "version": "4.1.1", + "version": "8.0.0", "description": "The profile sync helps developers synchronize data across multiple clients and devices in a privacy-preserving way. All data saved in the user storage database is encrypted client-side to preserve privacy. The user storage provides a modular design, giving developers the flexibility to construct and manage their storage spaces in a way that best suits their needs", "keywords": [ "MetaMask", @@ -100,12 +100,12 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/keyring-api": "^14.0.0", - "@metamask/keyring-controller": "^19.0.4", - "@metamask/network-controller": "^22.1.1", - "@metamask/snaps-sdk": "^6.7.0", - "@metamask/snaps-utils": "^8.3.0", + "@metamask/base-controller": "^8.0.0", + "@metamask/keyring-api": "^17.0.0", + "@metamask/keyring-controller": "^19.1.0", + "@metamask/network-controller": "^22.2.1", + "@metamask/snaps-sdk": "^6.17.1", + "@metamask/snaps-utils": "^8.10.0", "@noble/ciphers": "^0.5.2", "@noble/hashes": "^1.4.0", "immer": "^9.0.6", @@ -115,11 +115,11 @@ "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", - "@metamask/accounts-controller": "^21.0.2", + "@metamask/accounts-controller": "^24.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-internal-api": "^2.0.1", + "@metamask/keyring-internal-api": "^4.0.1", "@metamask/providers": "^18.1.1", - "@metamask/snaps-controllers": "^9.10.0", + "@metamask/snaps-controllers": "^9.19.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "ethers": "^6.12.0", @@ -133,11 +133,11 @@ "webextension-polyfill": "^0.12.0" }, "peerDependencies": { - "@metamask/accounts-controller": "^21.0.0", + "@metamask/accounts-controller": "^24.0.0", "@metamask/keyring-controller": "^19.0.0", "@metamask/network-controller": "^22.0.0", "@metamask/providers": "^18.1.0", - "@metamask/snaps-controllers": "^9.10.0", + "@metamask/snaps-controllers": "^9.19.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 1f72778d55d..ae029bb61d3 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { MOCK_ACCESS_TOKEN, @@ -193,6 +193,7 @@ describe('authentication/authentication-controller - getBearerToken() tests', () const { messenger } = createMockAuthenticationMessenger(); mockAuthenticationFlowEndpoints(); const originalState = mockSignedInState(); + // eslint-disable-next-line jest/no-conditional-in-test if (originalState.sessionData) { originalState.sessionData.accessToken = 'ACCESS_TOKEN_1'; @@ -222,6 +223,7 @@ describe('authentication/authentication-controller - getBearerToken() tests', () // Invalid/old state const originalState = mockSignedInState(); + // eslint-disable-next-line jest/no-conditional-in-test if (originalState.sessionData) { originalState.sessionData.accessToken = 'ACCESS_TOKEN_1'; @@ -280,6 +282,7 @@ describe('authentication/authentication-controller - getSessionProfile() tests', const { messenger } = createMockAuthenticationMessenger(); mockAuthenticationFlowEndpoints(); const originalState = mockSignedInState(); + // eslint-disable-next-line jest/no-conditional-in-test if (originalState.sessionData) { originalState.sessionData.profile.identifierId = 'ID_1'; @@ -310,6 +313,7 @@ describe('authentication/authentication-controller - getSessionProfile() tests', // Invalid/old state const originalState = mockSignedInState(); + // eslint-disable-next-line jest/no-conditional-in-test if (originalState.sessionData) { originalState.sessionData.profile.identifierId = 'ID_1'; @@ -339,10 +343,7 @@ describe('authentication/authentication-controller - getSessionProfile() tests', * @returns Auth Messenger */ function createAuthenticationMessenger() { - const messenger = new ControllerMessenger< - Actions | AllowedActions, - AllowedEvents - >(); + const messenger = new Messenger(); return messenger.getRestricted({ name: 'AuthenticationController', allowedActions: [ diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index 4b9494ee1ac..64bde38967b 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -1,7 +1,7 @@ import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; @@ -117,7 +117,7 @@ export type AllowedEvents = | KeyringControllerUnlockEvent; // Messenger -export type AuthenticationControllerMessenger = RestrictedControllerMessenger< +export type AuthenticationControllerMessenger = RestrictedMessenger< typeof controllerName, Actions | AllowedActions, Events | AllowedEvents, @@ -134,11 +134,11 @@ export default class AuthenticationController extends BaseController< AuthenticationControllerState, AuthenticationControllerMessenger > { - #metametrics: MetaMetricsAuth; + readonly #metametrics: MetaMetricsAuth; #isUnlocked = false; - #keyringController = { + readonly #keyringController = { setupLockedStateSubscriptions: () => { const { isUnlocked } = this.messagingSystem.call( 'KeyringController:getState', diff --git a/packages/profile-sync-controller/src/controllers/authentication/__fixtures__/mockResponses.ts b/packages/profile-sync-controller/src/controllers/authentication/__fixtures__/mockResponses.ts index 2831b0a7b82..9fbe257abb1 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/__fixtures__/mockResponses.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/__fixtures__/mockResponses.ts @@ -32,12 +32,9 @@ export const MOCK_JWT = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; export const MOCK_LOGIN_RESPONSE: LoginResponse = { token: MOCK_JWT, - // eslint-disable-next-line @typescript-eslint/naming-convention expires_in: new Date().toString(), profile: { - // eslint-disable-next-line @typescript-eslint/naming-convention identifier_id: 'MOCK_IDENTIFIER', - // eslint-disable-next-line @typescript-eslint/naming-convention profile_id: 'MOCK_PROFILE_ID', }, }; @@ -52,9 +49,8 @@ export const getMockAuthLoginResponse = () => { export const MOCK_ACCESS_TOKEN = `MOCK_ACCESS_TOKEN-${MOCK_JWT}`; export const MOCK_OATH_TOKEN_RESPONSE: OAuthTokenResponse = { - // eslint-disable-next-line @typescript-eslint/naming-convention access_token: MOCK_ACCESS_TOKEN, - // eslint-disable-next-line @typescript-eslint/naming-convention + expires_in: new Date().getTime(), }; diff --git a/packages/profile-sync-controller/src/controllers/authentication/services.ts b/packages/profile-sync-controller/src/controllers/authentication/services.ts index b4225b8aeeb..6a1697d6074 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/services.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/services.ts @@ -58,19 +58,20 @@ export async function getNonce(publicKey: string): Promise { */ export type LoginResponse = { token: string; - // eslint-disable-next-line @typescript-eslint/naming-convention + expires_in: string; /** * Contains anonymous information about the logged in profile. * - * @property identifier_id - a deterministic unique identifier on the method used to sign in - * @property profile_id - a unique id for a given profile - * @property metametrics_id - an anonymous server id + * identifier_id - a deterministic unique identifier on the method used to sign in + * + * profile_id - a unique id for a given profile + * + * metametrics_id - an anonymous server id */ profile: { - // eslint-disable-next-line @typescript-eslint/naming-convention identifier_id: string; - // eslint-disable-next-line @typescript-eslint/naming-convention + profile_id: string; }; }; @@ -101,10 +102,9 @@ export async function login( }, body: JSON.stringify({ signature, - // eslint-disable-next-line @typescript-eslint/naming-convention + raw_message: rawMessage, metametrics: { - // eslint-disable-next-line @typescript-eslint/naming-convention metametrics_id: clientMetaMetrics.metametricsId, agent: clientMetaMetrics.agent, }, @@ -130,9 +130,8 @@ export async function login( * The Auth API Token Response Shape */ export type OAuthTokenResponse = { - // eslint-disable-next-line @typescript-eslint/naming-convention access_token: string; - // eslint-disable-next-line @typescript-eslint/naming-convention + expires_in: number; }; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts index a9c18980a0d..cb32b423686 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts @@ -1,7 +1,6 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import type nock from 'nock'; -import { USER_STORAGE_FEATURE_NAMES } from '../../shared/storage-schema'; import { mockUserStorageMessenger } from './__fixtures__/mockMessenger'; import { mockEndpointBatchUpsertUserStorage, @@ -22,6 +21,7 @@ import * as AccountSyncControllerIntegrationModule from './account-syncing/contr import * as NetworkSyncIntegrationModule from './network-syncing/controller-integration'; import type { UserStorageBaseOptions } from './services'; import UserStorageController, { defaultState } from './UserStorageController'; +import { USER_STORAGE_FEATURE_NAMES } from '../../shared/storage-schema'; describe('user-storage/user-storage-controller - constructor() tests', () => { const arrangeMocks = () => { @@ -34,7 +34,6 @@ describe('user-storage/user-storage-controller - constructor() tests', () => { const { messengerMocks } = arrangeMocks(); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); expect(controller.state.isProfileSyncingEnabled).toBe(true); @@ -60,7 +59,6 @@ describe('user-storage/user-storage-controller - constructor() tests', () => { const { messengerMocks } = arrangeMocks(); new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, env: { isNetworkSyncingEnabled: true, }, @@ -88,7 +86,6 @@ describe('user-storage/user-storage-controller - performGetStorage() tests', () const { messengerMocks, mockAPI } = await arrangeMocks(); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); const result = await controller.performGetStorage( @@ -98,27 +95,6 @@ describe('user-storage/user-storage-controller - performGetStorage() tests', () expect(result).toBe(MOCK_STORAGE_DATA); }); - it('rejects if UserStorage is not enabled', async () => { - const { messengerMocks } = await arrangeMocks(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, - state: { - isProfileSyncingEnabled: false, - isProfileSyncingUpdateLoading: false, - isAccountSyncingInProgress: false, - hasAccountSyncingSyncedAtLeastOnce: false, - isAccountSyncingReadyToBeDispatched: false, - }, - }); - - await expect( - controller.performGetStorage( - `${USER_STORAGE_FEATURE_NAMES.notifications}.notification_settings`, - ), - ).rejects.toThrow(expect.any(Error)); - }); - it.each([ [ 'fails when no bearer token is found (auth errors)', @@ -146,7 +122,6 @@ describe('user-storage/user-storage-controller - performGetStorage() tests', () arrangeFailureCase(messengerMocks); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await expect( @@ -170,7 +145,6 @@ describe('user-storage/user-storage-controller - performGetStorageAllFeatureEntr const { messengerMocks, mockAPI } = await arrangeMocks(); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); const result = @@ -179,27 +153,6 @@ describe('user-storage/user-storage-controller - performGetStorageAllFeatureEntr expect(result).toStrictEqual([MOCK_STORAGE_DATA]); }); - it('rejects if UserStorage is not enabled', async () => { - const { messengerMocks } = await arrangeMocks(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, - state: { - isProfileSyncingEnabled: false, - isProfileSyncingUpdateLoading: false, - hasAccountSyncingSyncedAtLeastOnce: false, - isAccountSyncingReadyToBeDispatched: false, - isAccountSyncingInProgress: false, - }, - }); - - await expect( - controller.performGetStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.notifications, - ), - ).rejects.toThrow(expect.any(Error)); - }); - it.each([ [ 'fails when no bearer token is found (auth errors)', @@ -227,7 +180,6 @@ describe('user-storage/user-storage-controller - performGetStorageAllFeatureEntr arrangeFailureCase(messengerMocks); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await expect( @@ -251,7 +203,6 @@ describe('user-storage/user-storage-controller - performSetStorage() tests', () const { messengerMocks, mockAPI } = arrangeMocks(); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await controller.performSetStorage( @@ -261,28 +212,6 @@ describe('user-storage/user-storage-controller - performSetStorage() tests', () expect(mockAPI.isDone()).toBe(true); }); - it('rejects if UserStorage is not enabled', async () => { - const { messengerMocks } = arrangeMocks(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, - state: { - isProfileSyncingEnabled: false, - isProfileSyncingUpdateLoading: false, - hasAccountSyncingSyncedAtLeastOnce: false, - isAccountSyncingReadyToBeDispatched: false, - isAccountSyncingInProgress: false, - }, - }); - - await expect( - controller.performSetStorage( - `${USER_STORAGE_FEATURE_NAMES.notifications}.notification_settings`, - 'new data', - ), - ).rejects.toThrow(expect.any(Error)); - }); - it.each([ [ 'fails when no bearer token is found (auth errors)', @@ -310,7 +239,6 @@ describe('user-storage/user-storage-controller - performSetStorage() tests', () arrangeFailureCase(messengerMocks); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await expect( @@ -331,7 +259,6 @@ describe('user-storage/user-storage-controller - performSetStorage() tests', () }); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await expect( controller.performSetStorage( @@ -357,7 +284,6 @@ describe('user-storage/user-storage-controller - performBatchSetStorage() tests' const { messengerMocks, mockAPI } = arrangeMocks(); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await controller.performBatchSetStorage( @@ -367,28 +293,6 @@ describe('user-storage/user-storage-controller - performBatchSetStorage() tests' expect(mockAPI.isDone()).toBe(true); }); - it('rejects if UserStorage is not enabled', async () => { - const { messengerMocks } = arrangeMocks(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, - state: { - isProfileSyncingEnabled: false, - isProfileSyncingUpdateLoading: false, - hasAccountSyncingSyncedAtLeastOnce: false, - isAccountSyncingReadyToBeDispatched: false, - isAccountSyncingInProgress: false, - }, - }); - - await expect( - controller.performBatchSetStorage( - USER_STORAGE_FEATURE_NAMES.notifications, - [['notification_settings', 'new data']], - ), - ).rejects.toThrow(expect.any(Error)); - }); - it.each([ [ 'fails when no bearer token is found (auth errors)', @@ -416,7 +320,6 @@ describe('user-storage/user-storage-controller - performBatchSetStorage() tests' arrangeFailureCase(messengerMocks); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await expect( @@ -432,7 +335,6 @@ describe('user-storage/user-storage-controller - performBatchSetStorage() tests' const { messengerMocks, mockAPI } = arrangeMocks(500); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await expect( @@ -460,7 +362,6 @@ describe('user-storage/user-storage-controller - performBatchDeleteStorage() tes const { messengerMocks, mockAPI } = arrangeMocks(); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await controller.performBatchDeleteStorage('notifications', [ @@ -470,28 +371,6 @@ describe('user-storage/user-storage-controller - performBatchDeleteStorage() tes expect(mockAPI.isDone()).toBe(true); }); - it('rejects if UserStorage is not enabled', async () => { - const { messengerMocks } = arrangeMocks(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, - state: { - isProfileSyncingEnabled: false, - isProfileSyncingUpdateLoading: false, - hasAccountSyncingSyncedAtLeastOnce: false, - isAccountSyncingReadyToBeDispatched: false, - isAccountSyncingInProgress: false, - }, - }); - - await expect( - controller.performBatchDeleteStorage('notifications', [ - 'notification_settings', - 'notification_settings', - ]), - ).rejects.toThrow(expect.any(Error)); - }); - it.each([ [ 'fails when no bearer token is found (auth errors)', @@ -519,7 +398,6 @@ describe('user-storage/user-storage-controller - performBatchDeleteStorage() tes arrangeFailureCase(messengerMocks); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await expect( @@ -535,7 +413,6 @@ describe('user-storage/user-storage-controller - performBatchDeleteStorage() tes const { messengerMocks, mockAPI } = arrangeMocks(500); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await expect( @@ -563,7 +440,6 @@ describe('user-storage/user-storage-controller - performDeleteStorage() tests', const { messengerMocks, mockAPI } = await arrangeMocks(); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await controller.performDeleteStorage( @@ -574,27 +450,6 @@ describe('user-storage/user-storage-controller - performDeleteStorage() tests', expect(mockAPI.isDone()).toBe(true); }); - it('rejects if UserStorage is not enabled', async () => { - const { messengerMocks } = await arrangeMocks(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, - state: { - isProfileSyncingEnabled: false, - isProfileSyncingUpdateLoading: false, - hasAccountSyncingSyncedAtLeastOnce: false, - isAccountSyncingReadyToBeDispatched: false, - isAccountSyncingInProgress: false, - }, - }); - - await expect( - controller.performDeleteStorage( - `${USER_STORAGE_FEATURE_NAMES.notifications}.notification_settings`, - ), - ).rejects.toThrow(expect.any(Error)); - }); - it.each([ [ 'fails when no bearer token is found (auth errors)', @@ -622,7 +477,6 @@ describe('user-storage/user-storage-controller - performDeleteStorage() tests', arrangeFailureCase(messengerMocks); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await expect( @@ -637,7 +491,6 @@ describe('user-storage/user-storage-controller - performDeleteStorage() tests', const { messengerMocks, mockAPI } = await arrangeMocks(500); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await expect( @@ -664,7 +517,6 @@ describe('user-storage/user-storage-controller - performDeleteStorageAllFeatureE const { messengerMocks, mockAPI } = await arrangeMocks(); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await controller.performDeleteStorageAllFeatureEntries( @@ -675,27 +527,6 @@ describe('user-storage/user-storage-controller - performDeleteStorageAllFeatureE expect(mockAPI.isDone()).toBe(true); }); - it('rejects if UserStorage is not enabled', async () => { - const { messengerMocks } = await arrangeMocks(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, - state: { - isProfileSyncingEnabled: false, - isProfileSyncingUpdateLoading: false, - hasAccountSyncingSyncedAtLeastOnce: false, - isAccountSyncingReadyToBeDispatched: false, - isAccountSyncingInProgress: false, - }, - }); - - await expect( - controller.performDeleteStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.notifications, - ), - ).rejects.toThrow(expect.any(Error)); - }); - it.each([ [ 'fails when no bearer token is found (auth errors)', @@ -723,7 +554,6 @@ describe('user-storage/user-storage-controller - performDeleteStorageAllFeatureE arrangeFailureCase(messengerMocks); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await expect( @@ -738,7 +568,6 @@ describe('user-storage/user-storage-controller - performDeleteStorageAllFeatureE const { messengerMocks, mockAPI } = await arrangeMocks(500); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); await expect( @@ -761,27 +590,22 @@ describe('user-storage/user-storage-controller - getStorageKey() tests', () => { const { messengerMocks } = await arrangeMocks(); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); const result = await controller.getStorageKey(); expect(result).toBe(MOCK_STORAGE_KEY); }); - it('rejects if UserStorage is not enabled', async () => { + it('fails when no session identifier is found (auth error)', async () => { const { messengerMocks } = await arrangeMocks(); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, - state: { - isProfileSyncingEnabled: false, - isProfileSyncingUpdateLoading: false, - hasAccountSyncingSyncedAtLeastOnce: false, - isAccountSyncingReadyToBeDispatched: false, - isAccountSyncingInProgress: false, - }, }); + messengerMocks.mockAuthGetSessionProfile.mockRejectedValue( + new Error('MOCK FAILURE'), + ); + await expect(controller.getStorageKey()).rejects.toThrow(expect.any(Error)); }); }); @@ -797,7 +621,6 @@ describe('user-storage/user-storage-controller - disableProfileSyncing() tests', const { messengerMocks } = await arrangeMocks(); const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, }); expect(controller.state.isProfileSyncingEnabled).toBe(true); @@ -819,7 +642,6 @@ describe('user-storage/user-storage-controller - enableProfileSyncing() tests', const controller = new UserStorageController({ messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, state: { isProfileSyncingEnabled: false, isProfileSyncingUpdateLoading: false, @@ -862,7 +684,6 @@ describe('user-storage/user-storage-controller - syncInternalAccountsWithUserSto arrangeMocks(); const controller = new UserStorageController({ messenger, - getMetaMetricsState: () => true, env: { // We're only verifying that calling this controller method will call the integration module // The actual implementation is tested in the integration tests @@ -924,7 +745,6 @@ describe('user-storage/user-storage-controller - saveInternalAccountToUserStorag const { messenger, mockSaveInternalAccountToUserStorage } = arrangeMocks(); const controller = new UserStorageController({ messenger, - getMetaMetricsState: () => true, env: { // We're only verifying that calling this controller method will call the integration module // The actual implementation is tested in the integration tests @@ -970,15 +790,10 @@ describe('user-storage/user-storage-controller - syncNetworks() tests', () => { }; }; - const nonImportantControllerProps = () => ({ - getMetaMetricsState: () => true, - }); - it('should not be invoked if the feature is not enabled', async () => { const { messenger, mockGetSessionProfile, mockPerformMainNetworkSync } = arrangeMocks(); const controller = new UserStorageController({ - ...nonImportantControllerProps(), messenger, env: { isNetworkSyncingEnabled: false, @@ -997,7 +812,6 @@ describe('user-storage/user-storage-controller - syncNetworks() tests', () => { const { messenger, mockGetSessionProfile, mockPerformMainNetworkSync } = arrangeMocks(); const controller = new UserStorageController({ - ...nonImportantControllerProps(), messenger, env: { isNetworkSyncingEnabled: true, 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 ec620709e3a..f9108a81a22 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts @@ -7,7 +7,7 @@ import type { import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; @@ -27,21 +27,6 @@ import type { } from '@metamask/network-controller'; import type { HandleSnapRequest } from '@metamask/snaps-controllers'; -import { createSHA256Hash } from '../../shared/encryption'; -import type { UserStorageFeatureKeys } from '../../shared/storage-schema'; -import { - type UserStoragePathWithFeatureAndKey, - type UserStoragePathWithFeatureOnly, -} from '../../shared/storage-schema'; -import type { NativeScrypt } from '../../shared/types/encryption'; -import { createSnapSignMessageRequest } from '../authentication/auth-snap-requests'; -import type { - AuthenticationControllerGetBearerToken, - AuthenticationControllerGetSessionProfile, - AuthenticationControllerIsSignedIn, - AuthenticationControllerPerformSignIn, - AuthenticationControllerPerformSignOut, -} from '../authentication/AuthenticationController'; import { saveInternalAccountToUserStorage, syncInternalAccountsWithUserStorage, @@ -60,19 +45,21 @@ import { getUserStorageAllFeatureEntries, upsertUserStorage, } from './services'; - -// TODO: fix external dependencies -export declare type NotificationServicesControllerDisableNotificationServices = - { - type: `NotificationServicesController:disableNotificationServices`; - handler: () => Promise; - }; - -export declare type NotificationServicesControllerSelectIsNotificationServicesEnabled = - { - type: `NotificationServicesController:selectIsNotificationServicesEnabled`; - handler: () => boolean; - }; +import { createSHA256Hash } from '../../shared/encryption'; +import type { UserStorageFeatureKeys } from '../../shared/storage-schema'; +import { + type UserStoragePathWithFeatureAndKey, + type UserStoragePathWithFeatureOnly, +} from '../../shared/storage-schema'; +import type { NativeScrypt } from '../../shared/types/encryption'; +import { createSnapSignMessageRequest } from '../authentication/auth-snap-requests'; +import type { + AuthenticationControllerGetBearerToken, + AuthenticationControllerGetSessionProfile, + AuthenticationControllerIsSignedIn, + AuthenticationControllerPerformSignIn, + AuthenticationControllerPerformSignOut, +} from '../authentication/AuthenticationController'; const controllerName = 'UserStorageController'; @@ -170,6 +157,7 @@ type ControllerConfig = { /** * Callback that fires when network sync adds a network * This is used for analytics. + * * @param profileId - ID for a given User (shared cross devices once authenticated) * @param chainId - Chain ID for the network added (in hex) */ @@ -177,6 +165,7 @@ type ControllerConfig = { /** * Callback that fires when network sync updates a network * This is used for analytics. + * * @param profileId - ID for a given User (shared cross devices once authenticated) * @param chainId - Chain ID for the network added (in hex) */ @@ -184,6 +173,7 @@ type ControllerConfig = { /** * Callback that fires when network sync deletes a network * This is used for analytics. + * * @param profileId - ID for a given User (shared cross devices once authenticated) * @param chainId - Chain ID for the network added (in hex) */ @@ -202,6 +192,9 @@ type ActionsObj = CreateActionsObj< | 'performGetStorage' | 'performGetStorageAllFeatureEntries' | 'performSetStorage' + | 'performBatchSetStorage' + | 'performDeleteStorage' + | 'performBatchDeleteStorage' | 'getStorageKey' | 'enableProfileSyncing' | 'disableProfileSyncing' @@ -221,6 +214,12 @@ export type UserStorageControllerPerformGetStorageAllFeatureEntries = ActionsObj['performGetStorageAllFeatureEntries']; export type UserStorageControllerPerformSetStorage = ActionsObj['performSetStorage']; +export type UserStorageControllerPerformBatchSetStorage = + ActionsObj['performBatchSetStorage']; +export type UserStorageControllerPerformDeleteStorage = + ActionsObj['performDeleteStorage']; +export type UserStorageControllerPerformBatchDeleteStorage = + ActionsObj['performBatchDeleteStorage']; export type UserStorageControllerGetStorageKey = ActionsObj['getStorageKey']; export type UserStorageControllerEnableProfileSyncing = ActionsObj['enableProfileSyncing']; @@ -242,9 +241,6 @@ export type AllowedActions = | AuthenticationControllerPerformSignIn | AuthenticationControllerIsSignedIn | AuthenticationControllerPerformSignOut - // Metamask Notifications - | NotificationServicesControllerDisableNotificationServices - | NotificationServicesControllerSelectIsNotificationServicesEnabled // Account Syncing | AccountsControllerListAccountsAction | AccountsControllerUpdateAccountMetadataAction @@ -260,23 +256,11 @@ export type UserStorageControllerStateChangeEvent = ControllerStateChangeEvent< typeof controllerName, UserStorageControllerState >; -export type UserStorageControllerAccountSyncingInProgress = { - type: `${typeof controllerName}:accountSyncingInProgress`; - payload: [boolean]; -}; -export type UserStorageControllerAccountSyncingComplete = { - type: `${typeof controllerName}:accountSyncingComplete`; - payload: [boolean]; -}; -export type Events = - | UserStorageControllerStateChangeEvent - | UserStorageControllerAccountSyncingInProgress - | UserStorageControllerAccountSyncingComplete; + +export type Events = UserStorageControllerStateChangeEvent; export type AllowedEvents = | UserStorageControllerStateChangeEvent - | UserStorageControllerAccountSyncingInProgress - | UserStorageControllerAccountSyncingComplete | KeyringControllerLockEvent | KeyringControllerUnlockEvent // Account Syncing Events @@ -286,7 +270,7 @@ export type AllowedEvents = | NetworkControllerNetworkRemovedEvent; // Messenger -export type UserStorageControllerMessenger = RestrictedControllerMessenger< +export type UserStorageControllerMessenger = RestrictedMessenger< typeof controllerName, Actions | AllowedActions, Events | AllowedEvents, @@ -309,12 +293,12 @@ export default class UserStorageController extends BaseController< > { // This is replaced with the actual value in the constructor // We will remove this once the feature will be released - #env = { + readonly #env = { isAccountSyncingEnabled: false, isNetworkSyncingEnabled: false, }; - #auth = { + readonly #auth = { getBearerToken: async () => { return await this.messagingSystem.call( 'AuthenticationController:getBearerToken', @@ -326,7 +310,7 @@ export default class UserStorageController extends BaseController< ); return sessionProfile?.profileId; }, - isAuthEnabled: () => { + isSignedIn: () => { return this.messagingSystem.call('AuthenticationController:isSignedIn'); }, signIn: async () => { @@ -341,24 +325,11 @@ export default class UserStorageController extends BaseController< }, }; - #config?: ControllerConfig; - - #notificationServices = { - disableNotificationServices: async () => { - return await this.messagingSystem.call( - 'NotificationServicesController:disableNotificationServices', - ); - }, - selectIsNotificationServicesEnabled: async () => { - return this.messagingSystem.call( - 'NotificationServicesController:selectIsNotificationServicesEnabled', - ); - }, - }; + readonly #config?: ControllerConfig; #isUnlocked = false; - #keyringController = { + readonly #keyringController = { setupLockedStateSubscriptions: () => { const { isUnlocked } = this.messagingSystem.call( 'KeyringController:getState', @@ -375,16 +346,13 @@ export default class UserStorageController extends BaseController< }, }; - #nativeScryptCrypto: NativeScrypt | undefined = undefined; - - getMetaMetricsState: () => boolean; + readonly #nativeScryptCrypto: NativeScrypt | undefined = undefined; constructor({ messenger, state, env, config, - getMetaMetricsState, nativeScryptCrypto, }: { messenger: UserStorageControllerMessenger; @@ -394,7 +362,6 @@ export default class UserStorageController extends BaseController< isAccountSyncingEnabled?: boolean; isNetworkSyncingEnabled?: boolean; }; - getMetaMetricsState: () => boolean; nativeScryptCrypto?: NativeScrypt; }) { super({ @@ -408,7 +375,6 @@ export default class UserStorageController extends BaseController< this.#env.isNetworkSyncingEnabled = Boolean(env?.isNetworkSyncingEnabled); this.#config = config; - this.getMetaMetricsState = getMetaMetricsState; this.#keyringController.setupLockedStateSubscriptions(); this.#registerMessageHandlers(); this.#nativeScryptCrypto = nativeScryptCrypto; @@ -455,6 +421,21 @@ export default class UserStorageController extends BaseController< this.performSetStorage.bind(this), ); + this.messagingSystem.registerActionHandler( + 'UserStorageController:performBatchSetStorage', + this.performBatchSetStorage.bind(this), + ); + + this.messagingSystem.registerActionHandler( + 'UserStorageController:performDeleteStorage', + this.performDeleteStorage.bind(this), + ); + + this.messagingSystem.registerActionHandler( + 'UserStorageController:performBatchDeleteStorage', + this.performBatchDeleteStorage.bind(this), + ); + this.messagingSystem.registerActionHandler( 'UserStorageController:getStorageKey', this.getStorageKey.bind(this), @@ -499,8 +480,8 @@ export default class UserStorageController extends BaseController< try { this.#setIsProfileSyncingUpdateLoading(true); - const authEnabled = this.#auth.isAuthEnabled(); - if (!authEnabled) { + const isSignedIn = this.#auth.isSignedIn(); + if (!isSignedIn) { await this.#auth.signIn(); } @@ -518,14 +499,6 @@ export default class UserStorageController extends BaseController< } } - public async setIsProfileSyncingEnabled( - isProfileSyncingEnabled: boolean, - ): Promise { - this.update((state) => { - state.isProfileSyncingEnabled = isProfileSyncingEnabled; - }); - } - public async disableProfileSyncing(): Promise { const isAlreadyDisabled = !this.state.isProfileSyncingEnabled; if (isAlreadyDisabled) { @@ -535,19 +508,6 @@ export default class UserStorageController extends BaseController< try { this.#setIsProfileSyncingUpdateLoading(true); - const isNotificationServicesEnabled = - await this.#notificationServices.selectIsNotificationServicesEnabled(); - - if (isNotificationServicesEnabled) { - await this.#notificationServices.disableNotificationServices(); - } - - const isMetaMetricsParticipation = this.getMetaMetricsState(); - - if (!isMetaMetricsParticipation) { - await this.#auth.signOut(); - } - this.#setIsProfileSyncingUpdateLoading(false); this.update((state) => { @@ -572,8 +532,6 @@ export default class UserStorageController extends BaseController< public async performGetStorage( path: UserStoragePathWithFeatureAndKey, ): Promise { - this.#assertProfileSyncingEnabled(); - const { bearerToken, storageKey } = await this.#getStorageKeyAndBearerToken(); @@ -597,8 +555,6 @@ export default class UserStorageController extends BaseController< public async performGetStorageAllFeatureEntries( path: UserStoragePathWithFeatureOnly, ): Promise { - this.#assertProfileSyncingEnabled(); - const { bearerToken, storageKey } = await this.#getStorageKeyAndBearerToken(); @@ -624,8 +580,6 @@ export default class UserStorageController extends BaseController< path: UserStoragePathWithFeatureAndKey, value: string, ): Promise { - this.#assertProfileSyncingEnabled(); - const { bearerToken, storageKey } = await this.#getStorageKeyAndBearerToken(); @@ -651,8 +605,6 @@ export default class UserStorageController extends BaseController< path: FeatureName, values: [UserStorageFeatureKeys, string][], ): Promise { - this.#assertProfileSyncingEnabled(); - const { bearerToken, storageKey } = await this.#getStorageKeyAndBearerToken(); @@ -673,8 +625,6 @@ export default class UserStorageController extends BaseController< public async performDeleteStorage( path: UserStoragePathWithFeatureAndKey, ): Promise { - this.#assertProfileSyncingEnabled(); - const { bearerToken, storageKey } = await this.#getStorageKeyAndBearerToken(); @@ -695,8 +645,6 @@ export default class UserStorageController extends BaseController< public async performDeleteStorageAllFeatureEntries( path: UserStoragePathWithFeatureOnly, ): Promise { - this.#assertProfileSyncingEnabled(); - const { bearerToken, storageKey } = await this.#getStorageKeyAndBearerToken(); @@ -721,8 +669,6 @@ export default class UserStorageController extends BaseController< path: FeatureName, values: UserStorageFeatureKeys[], ): Promise { - this.#assertProfileSyncingEnabled(); - const { bearerToken, storageKey } = await this.#getStorageKeyAndBearerToken(); @@ -740,21 +686,14 @@ export default class UserStorageController extends BaseController< * @returns the storage key */ public async getStorageKey(): Promise { - this.#assertProfileSyncingEnabled(); const storageKey = await this.#createStorageKey(); return storageKey; } - #assertProfileSyncingEnabled(): void { - if (!this.state.isProfileSyncingEnabled) { - throw new Error( - `${controllerName}: Unable to call method, user is not authenticated`, - ); - } - } - /** * Utility to get the bearer token and storage key + * + * @returns the bearer token and storage key */ async #getStorageKeyAndBearerToken(): Promise<{ bearerToken: string; @@ -881,6 +820,7 @@ export default class UserStorageController extends BaseController< /** * Saves an individual internal account to the user storage. + * * @param internalAccount - The internal account to save */ async saveInternalAccountToUserStorage( 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 6281b4d0d79..44d1c3e472e 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,5 +1,5 @@ import type { NotNamespacedBy } from '@metamask/base-controller'; -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { MOCK_STORAGE_KEY_SIGNATURE } from '.'; import type { @@ -38,6 +38,7 @@ type ExternalEvents = NotNamespacedBy< /** * creates a custom user storage messenger, in case tests need different permissions + * * @param props - overrides * @param props.overrideEvents - override events * @returns base messenger, and messenger. You can pass this into the mocks below to mock messenger calls @@ -45,10 +46,7 @@ type ExternalEvents = NotNamespacedBy< export function createCustomUserStorageMessenger(props?: { overrideEvents?: ExternalEvents[]; }) { - const baseMessenger = new ControllerMessenger< - AllowedActions, - AllowedEvents - >(); + const baseMessenger = new Messenger(); const messenger = baseMessenger.getRestricted({ name: 'UserStorageController', allowedActions: [ @@ -60,8 +58,6 @@ export function createCustomUserStorageMessenger(props?: { 'AuthenticationController:isSignedIn', 'AuthenticationController:performSignOut', 'AuthenticationController:performSignIn', - 'NotificationServicesController:disableNotificationServices', - 'NotificationServicesController:selectIsNotificationServicesEnabled', 'AccountsController:listAccounts', 'AccountsController:updateAccountMetadata', 'NetworkController:getState', @@ -85,12 +81,13 @@ export function createCustomUserStorageMessenger(props?: { } type OverrideMessengers = { - baseMessenger: ControllerMessenger; + baseMessenger: Messenger; messenger: UserStorageControllerMessenger; }; /** * Jest Mock Utility to generate a mock User Storage Messenger + * * @param overrideMessengers - override messengers if need to modify the underlying permissions * @returns series of mocks to actions that can be called */ @@ -128,14 +125,6 @@ export function mockUserStorageMessenger( 'AuthenticationController:performSignOut', ); - const mockNotificationServicesIsEnabled = typedMockFn( - 'NotificationServicesController:selectIsNotificationServicesEnabled', - ).mockReturnValue(true); - - const mockNotificationServicesDisableNotifications = typedMockFn( - 'NotificationServicesController:disableNotificationServices', - ).mockResolvedValue(); - const mockKeyringAddNewAccount = typedMockFn( 'KeyringController:addNewAccount', ); @@ -205,20 +194,6 @@ export function mockUserStorageMessenger( return mockAuthIsSignedIn(); } - if ( - actionType === - 'NotificationServicesController:selectIsNotificationServicesEnabled' - ) { - return mockNotificationServicesIsEnabled(); - } - - if ( - actionType === - 'NotificationServicesController:disableNotificationServices' - ) { - return mockNotificationServicesDisableNotifications(); - } - if (actionType === 'AuthenticationController:performSignOut') { return mockAuthPerformSignOut(); } @@ -273,8 +248,6 @@ export function mockUserStorageMessenger( mockAuthGetSessionProfile, mockAuthPerformSignIn, mockAuthIsSignedIn, - mockNotificationServicesIsEnabled, - mockNotificationServicesDisableNotifications, mockAuthPerformSignOut, mockKeyringAddNewAccount, mockAccountsUpdateAccountMetadata, diff --git a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockResponses.ts b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockResponses.ts index fc81ff44b3c..328e9fddd32 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockResponses.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockResponses.ts @@ -1,3 +1,8 @@ +import { + MOCK_ENCRYPTED_STORAGE_DATA, + MOCK_STORAGE_DATA, + MOCK_STORAGE_KEY, +} from './mockStorage'; import type { UserStoragePathWithFeatureAndKey, UserStoragePathWithFeatureOnly, @@ -11,11 +16,6 @@ import type { GetUserStorageResponse, } from '../services'; import { USER_STORAGE_ENDPOINT } from '../services'; -import { - MOCK_ENCRYPTED_STORAGE_DATA, - MOCK_STORAGE_DATA, - MOCK_STORAGE_KEY, -} from './mockStorage'; type MockResponse = { url: string; @@ -38,6 +38,7 @@ export const getMockUserStorageEndpoint = ( /** * Creates a mock GET user-storage response + * * @param data - data to encrypt * @returns a realistic GET Response Body */ @@ -52,6 +53,7 @@ export async function createMockGetStorageResponse( /** * Creates a mock GET ALL user-storage response + * * @param dataArr - array of data to encrypt * @returns a realistic GET ALL Response Body */ @@ -72,6 +74,7 @@ export async function createMockAllFeatureEntriesResponse( /** * Creates a mock user-storage api GET request + * * @param path - path of the GET Url * @returns mock GET API request. Can be used by e2e or unit mock servers */ @@ -87,6 +90,7 @@ export async function getMockUserStorageGetResponse( /** * Creates a mock user-storage api GET ALL request + * * @param path - path of the GET url * @param dataArr - data to encrypt * @returns mock GET ALL API request. Can be used by e2e or unit mock servers 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 8f018271a9c..d5ef67d3d50 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,10 +1,5 @@ import nock from 'nock'; -import { - USER_STORAGE_FEATURE_NAMES, - type UserStoragePathWithFeatureAndKey, - type UserStoragePathWithFeatureOnly, -} from '../../../shared/storage-schema'; import { getMockUserStorageGetResponse, getMockUserStoragePutResponse, @@ -14,6 +9,11 @@ import { deleteMockUserStorageAllFeatureEntriesResponse, deleteMockUserStorageResponse, } from './mockResponses'; +import { + USER_STORAGE_FEATURE_NAMES, + type UserStoragePathWithFeatureAndKey, + type UserStoragePathWithFeatureOnly, +} from '../../../shared/storage-schema'; type MockReply = { status: nock.StatusCode; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/test-utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/test-utils.ts index 6bb1b38689d..e2e30782502 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/test-utils.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/test-utils.ts @@ -1,14 +1,15 @@ import type nock from 'nock'; +import { MOCK_STORAGE_KEY } from './mockStorage'; import encryption from '../../../shared/encryption/encryption'; import type { GetUserStorageAllFeatureEntriesResponse, GetUserStorageResponse, } from '../services'; -import { MOCK_STORAGE_KEY } from './mockStorage'; /** * Test Utility - creates a realistic mock user-storage entry + * * @param data - data to encrypt * @returns user storage entry */ @@ -26,6 +27,7 @@ export async function createMockUserStorageEntry( /** * Test Utility - creates a realistic mock user-storage get-all entry + * * @param data - data array to encrypt * @returns user storage entry */ @@ -37,6 +39,7 @@ export async function createMockUserStorageEntries( /** * Test Utility - decrypts a realistic batch upsert payload + * * @param requestBody - nock body * @param storageKey - storage key * @returns decrypted body @@ -86,7 +89,7 @@ export const waitFor = async ( assertionFn(); clearInterval(intervalId); resolve(); - } catch (error) { + } catch { if (Date.now() - startTime >= timeoutMs) { clearInterval(intervalId); reject(new Error(`waitFor: timeout reached after ${timeoutMs}ms`)); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/mockAccounts.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/mockAccounts.ts index faf0e027fa4..86bdf355a4d 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/mockAccounts.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/mockAccounts.ts @@ -8,6 +8,7 @@ import { mapInternalAccountToUserStorageAccount } from '../utils'; /** * Map an array of internal accounts to an array of user storage accounts * Only used for testing purposes + * * @param internalAccounts - An array of internal accounts * @returns An array of user storage accounts */ @@ -17,6 +18,7 @@ const mapInternalAccountsListToUserStorageAccountsList = ( /** * Get a random default account name from the list of localized default account names + * * @returns A random default account name */ export const getMockRandomDefaultAccountName = () => diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/test-utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/test-utils.ts index fc03dd23431..eada1d17c9b 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/test-utils.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/test-utils.ts @@ -1,9 +1,9 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { MOCK_INTERNAL_ACCOUNTS } from './mockAccounts'; import { createSHA256Hash } from '../../../../shared/encryption'; import { mockUserStorageMessenger } from '../../__fixtures__/mockMessenger'; import { mapInternalAccountToUserStorageAccount } from '../utils'; -import { MOCK_INTERNAL_ACCOUNTS } from './mockAccounts'; /** * Test Utility - create a mock user storage messenger for account syncing tests @@ -38,6 +38,7 @@ export function mockUserStorageMessengerForAccountSyncing(options?: { /** * Test Utility - creates a realistic expected batch upsert payload + * * @param data - data supposed to be upserted * @param storageKey - storage key * @returns expected body @@ -54,6 +55,7 @@ export function createExpectedAccountSyncBatchUpsertBody( /** * Test Utility - creates a realistic expected batch delete payload + * * @param data - data supposed to be deleted * @param storageKey - storage key * @returns expected body diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts index f1638d67f35..42e10302dcd 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts @@ -1,5 +1,17 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { + MOCK_INTERNAL_ACCOUNTS, + MOCK_USER_STORAGE_ACCOUNTS, +} from './__fixtures__/mockAccounts'; +import { + createExpectedAccountSyncBatchDeleteBody, + createExpectedAccountSyncBatchUpsertBody, + mockUserStorageMessengerForAccountSyncing, +} from './__fixtures__/test-utils'; +import * as AccountSyncingControllerIntegrationModule from './controller-integration'; +import * as AccountSyncingUtils from './sync-utils'; +import * as AccountsUserStorageModule from './utils'; import UserStorageController, { USER_STORAGE_FEATURE_NAMES } from '..'; import { MOCK_STORAGE_KEY } from '../__fixtures__'; import { @@ -13,18 +25,6 @@ import { createMockUserStorageEntries, decryptBatchUpsertBody, } from '../__fixtures__/test-utils'; -import { - MOCK_INTERNAL_ACCOUNTS, - MOCK_USER_STORAGE_ACCOUNTS, -} from './__fixtures__/mockAccounts'; -import { - createExpectedAccountSyncBatchDeleteBody, - createExpectedAccountSyncBatchUpsertBody, - mockUserStorageMessengerForAccountSyncing, -} from './__fixtures__/test-utils'; -import * as AccountSyncingControllerIntegrationModule from './controller-integration'; -import * as AccountSyncingUtils from './sync-utils'; -import * as AccountsUserStorageModule from './utils'; const baseState = { isProfileSyncingEnabled: true, @@ -48,7 +48,6 @@ const arrangeMocks = async ({ env: { isAccountSyncingEnabled, }, - getMetaMetricsState: () => true, state: { ...baseState, ...stateOverrides, @@ -258,6 +257,7 @@ describe('user-storage/account-syncing/controller-integration - syncInternalAcco USER_STORAGE_FEATURE_NAMES.accounts, undefined, async (_uri, requestBody) => { + // eslint-disable-next-line jest/no-conditional-in-test if (typeof requestBody === 'string') { return; } @@ -368,7 +368,7 @@ describe('user-storage/account-syncing/controller-integration - syncInternalAcco }); describe('Fires the onAccountSyncErroneousSituation callback on erroneous situations', () => { - it('And logs if the final state is incorrect', async () => { + it('and logs if the final state is incorrect', async () => { const onAccountSyncErroneousSituation = jest.fn(); const { config, options, userStorageList, accountsList } = @@ -383,6 +383,7 @@ describe('user-storage/account-syncing/controller-integration - syncInternalAcco ); expect(onAccountSyncErroneousSituation).toHaveBeenCalledTimes(2); + // eslint-disable-next-line jest/prefer-strict-equal expect(onAccountSyncErroneousSituation.mock.calls).toEqual([ [ 'An account was present in the user storage accounts list but was not found in the internal accounts list after the sync', @@ -404,7 +405,7 @@ describe('user-storage/account-syncing/controller-integration - syncInternalAcco ]); }); - it('And logs if the final state is correct', async () => { + it('and logs if the final state is correct', async () => { const onAccountSyncErroneousSituation = jest.fn(); const { config, options, userStorageList, accountsList } = @@ -427,6 +428,7 @@ describe('user-storage/account-syncing/controller-integration - syncInternalAcco ); expect(onAccountSyncErroneousSituation).toHaveBeenCalledTimes(2); + // eslint-disable-next-line jest/prefer-strict-equal expect(onAccountSyncErroneousSituation.mock.calls).toEqual([ [ 'An account was present in the user storage accounts list but was not found in the internal accounts list after the sync', @@ -474,6 +476,7 @@ describe('user-storage/account-syncing/controller-integration - syncInternalAcco USER_STORAGE_FEATURE_NAMES.accounts, undefined, async (_uri, requestBody) => { + // eslint-disable-next-line jest/no-conditional-in-test if (typeof requestBody === 'string') { return; } diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.ts index 027f119879e..d8b9cae7293 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.ts @@ -1,7 +1,6 @@ import { isEvmAccountType } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { USER_STORAGE_FEATURE_NAMES } from '../../../shared/storage-schema'; import { canPerformAccountSyncing, getInternalAccountsList, @@ -13,9 +12,11 @@ import { isNameDefaultAccountName, mapInternalAccountToUserStorageAccount, } from './utils'; +import { USER_STORAGE_FEATURE_NAMES } from '../../../shared/storage-schema'; /** * Saves an individual internal account to the user storage. + * * @param internalAccount - The internal account to save * @param config - parameters used for saving the internal account * @param options - parameters used for saving the internal account @@ -44,7 +45,7 @@ export async function saveInternalAccountToUserStorage( await getUserStorageControllerInstance().performSetStorage( // ESLint is confused here. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${USER_STORAGE_FEATURE_NAMES.accounts}.${internalAccount.address}`, JSON.stringify(mappedAccount), ); @@ -59,6 +60,7 @@ export async function saveInternalAccountToUserStorage( /** * Saves the list of internal accounts to the user storage. + * * @param config - parameters used for saving the list of internal accounts * @param options - parameters used for saving the list of internal accounts */ @@ -106,6 +108,7 @@ type SyncInternalAccountsWithUserStorageConfig = AccountSyncingConfig & { * Syncs the internal accounts list with the user storage accounts list. * This method is used to make sure that the internal accounts list is up-to-date with the user storage accounts list and vice-versa. * It will add new accounts to the internal accounts list, update/merge conflicting names and re-upload the results in some cases to the user storage. + * * @param config - parameters used for syncing the internal accounts list with the user storage accounts list * @param options - parameters used for syncing the internal accounts list with the user storage accounts list */ @@ -120,7 +123,7 @@ export async function syncInternalAccountsWithUserStorage( } const { - maxNumberOfAccountsToAdd = 100, + maxNumberOfAccountsToAdd = Infinity, onAccountAdded, onAccountNameUpdated, onAccountSyncErroneousSituation, @@ -178,9 +181,8 @@ export async function syncInternalAccountsWithUserStorage( // Second step: compare account names // Get the internal accounts list again since new accounts might have been added in the previous step - const refreshedInternalAccountsList = await getInternalAccountsList( - options, - ); + const refreshedInternalAccountsList = + await getInternalAccountsList(options); const newlyAddedAccounts = refreshedInternalAccountsList.filter( (account) => diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/setup-subscriptions.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/setup-subscriptions.ts index 5e1732e8428..97336fda9df 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/setup-subscriptions.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/setup-subscriptions.ts @@ -4,6 +4,7 @@ import type { AccountSyncingConfig, AccountSyncingOptions } from './types'; /** * Initialize and setup events to listen to for account syncing + * * @param config - configuration parameters * @param options - parameters used for initializing and enabling account syncing */ @@ -15,7 +16,7 @@ export function setupAccountSyncingSubscriptions( getMessenger().subscribe( 'AccountsController:accountAdded', - // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (account) => { if ( !canPerformAccountSyncing(config, options) || @@ -31,7 +32,7 @@ export function setupAccountSyncingSubscriptions( getMessenger().subscribe( 'AccountsController:accountRenamed', - // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (account) => { if ( !canPerformAccountSyncing(config, options) || diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.test.ts index c19d698c227..f8f75fc8605 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.test.ts @@ -76,13 +76,12 @@ describe('user-storage/account-syncing/sync-utils', () => { const options: AccountSyncingOptions = { getMessenger: jest.fn().mockReturnValue({ - call: jest - .fn() - .mockImplementation((controllerAndActionName) => - controllerAndActionName === 'AccountsController:listAccounts' - ? internalAccounts - : null, - ), + call: jest.fn().mockImplementation((controllerAndActionName) => + // eslint-disable-next-line jest/no-conditional-in-test + controllerAndActionName === 'AccountsController:listAccounts' + ? internalAccounts + : null, + ), }), getUserStorageControllerInstance: jest.fn(), }; @@ -90,7 +89,8 @@ describe('user-storage/account-syncing/sync-utils', () => { jest .spyOn(utils, 'doesInternalAccountHaveCorrectKeyringType') .mockImplementation( - (account) => account.metadata.keyring.type === KeyringTypes.hd, + (account) => + account.metadata.keyring.type === String(KeyringTypes.hd), ); const result = await getInternalAccountsList(options); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.ts index c20a6ca44d0..a868b939574 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.ts @@ -1,15 +1,16 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { USER_STORAGE_FEATURE_NAMES } from '../../../shared/storage-schema'; import type { AccountSyncingConfig, AccountSyncingOptions, UserStorageAccount, } from './types'; import { doesInternalAccountHaveCorrectKeyringType } from './utils'; +import { USER_STORAGE_FEATURE_NAMES } from '../../../shared/storage-schema'; /** * Checks if account syncing can be performed based on a set of conditions + * * @param config - configuration parameters * @param options - parameters used for checking if account syncing can be performed * @returns Returns true if account syncing can be performed, false otherwise. @@ -41,14 +42,15 @@ export function canPerformAccountSyncing( /** * Get the list of internal accounts + * * @param options - parameters used for getting the list of internal accounts + * @returns the list of internal accounts */ export async function getInternalAccountsList( options: AccountSyncingOptions, ): Promise { const { getMessenger } = options; - // eslint-disable-next-line @typescript-eslint/await-thenable const internalAccountsList = await getMessenger().call( 'AccountsController:listAccounts', ); @@ -60,7 +62,9 @@ export async function getInternalAccountsList( /** * Get the list of user storage accounts + * * @param options - parameters used for getting the list of user storage accounts + * @returns the list of user storage accounts */ export async function getUserStorageAccountsList( options: AccountSyncingOptions, diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/types.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/types.ts index 745107f8145..8180a12fd08 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/types.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/types.ts @@ -1,9 +1,9 @@ -import type { UserStorageControllerMessenger } from '../UserStorageController'; -import type UserStorageController from '../UserStorageController'; import type { USER_STORAGE_VERSION_KEY, USER_STORAGE_VERSION, } from './constants'; +import type { UserStorageControllerMessenger } from '../UserStorageController'; +import type UserStorageController from '../UserStorageController'; export type UserStorageAccount = { /** diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/utils.ts index 59a8048f106..c6b9bd48509 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/utils.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/utils.ts @@ -24,6 +24,7 @@ export const isNameDefaultAccountName = (name: string) => { /** * Map an internal account to a user storage account + * * @param internalAccount - An internal account * @returns A user storage account */ @@ -44,11 +45,12 @@ export const mapInternalAccountToUserStorageAccount = ( /** * Checks if the given internal account has the correct keyring type. + * * @param account - The internal account to check * @returns Returns true if the internal account has the correct keyring type, false otherwise. */ export function doesInternalAccountHaveCorrectKeyringType( account: InternalAccount, ) { - return account.metadata.keyring.type === KeyringTypes.hd; + return account.metadata.keyring.type === String(KeyringTypes.hd); } diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/add-network-utils.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/add-network-utils.test.ts index ae394585f53..834c0bbdfaf 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/add-network-utils.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/add-network-utils.test.ts @@ -55,6 +55,7 @@ describe('getBoundedNetworksToAdd()', () => { /** * Test Utility - creates an array of network configurations + * * @param chains - list of chains to create * @returns array of mock network configurations */ diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/add-network-utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/add-network-utils.ts index 7cb7e12d0f3..699ed096f21 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/add-network-utils.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/add-network-utils.ts @@ -5,6 +5,7 @@ export const MAX_NETWORKS_SIZE = 50; /** * Calculates the available space to add new networks * exported for testability. + * * @param originalListSize - size of original list * @param maxSize - max size * @returns a positive number on the available space diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.test.ts index 35cab221557..d4c47623037 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.test.ts @@ -1,12 +1,5 @@ import log from 'loglevel'; -import { MOCK_STORAGE_KEY } from '../__fixtures__'; -import { - createCustomUserStorageMessenger, - mockUserStorageMessenger, -} from '../__fixtures__/mockMessenger'; -import { waitFor } from '../__fixtures__/test-utils'; -import type { UserStorageBaseOptions } from '../services'; import { createMockNetworkConfiguration, createMockRemoteNetworkConfiguration, @@ -19,6 +12,13 @@ import * as ControllerIntegrationModule from './controller-integration'; import * as ServicesModule from './services'; import * as SyncAllModule from './sync-all'; import * as SyncMutationsModule from './sync-mutations'; +import { MOCK_STORAGE_KEY } from '../__fixtures__'; +import { + createCustomUserStorageMessenger, + mockUserStorageMessenger, +} from '../__fixtures__/mockMessenger'; +import { waitFor } from '../__fixtures__/test-utils'; +import type { UserStorageBaseOptions } from '../services'; jest.mock('loglevel', () => { const actual = jest.requireActual('loglevel'); @@ -29,7 +29,7 @@ jest.mock('loglevel', () => { warn: jest.fn(), }, // Mocking an ESModule. - // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, }; }); @@ -143,6 +143,7 @@ describe('network-syncing/controller-integration - startNetworkSyncing()', () => /** * Test Utility - arrange mocks and parameters + * * @returns the mocks and parameters used when testing `startNetworkSyncing()` */ function arrangeMocks() { @@ -338,6 +339,7 @@ describe('network-syncing/controller-integration - performMainSync()', () => { /** * Jest Mock Utility - create suite of mocks for tests + * * @returns mocks for tests */ function arrangeMocks() { diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.ts index 359520d8294..56844dffafb 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.ts @@ -1,13 +1,13 @@ import type { NetworkConfiguration } from '@metamask/network-controller'; import log from 'loglevel'; -import type { UserStorageBaseOptions } from '../services'; -import type { UserStorageControllerMessenger } from '../UserStorageController'; import { getBoundedNetworksToAdd } from './add-network-utils'; import { getAllRemoteNetworks } from './services'; import { findNetworksToUpdate } from './sync-all'; import { batchUpdateNetworks, deleteNetwork } from './sync-mutations'; import { createUpdateNetworkProps } from './update-network-utils'; +import type { UserStorageBaseOptions } from '../services'; +import type { UserStorageControllerMessenger } from '../UserStorageController'; type StartNetworkSyncingProps = { messenger: UserStorageControllerMessenger; @@ -47,7 +47,7 @@ export function startNetworkSyncing(props: StartNetworkSyncingProps) { try { messenger.subscribe( 'NetworkController:networkRemoved', - // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (networkConfiguration) => { try { // If blocked (e.g. we have not yet performed a main-sync), then we should not perform any mutations @@ -79,6 +79,7 @@ export function startNetworkSyncing(props: StartNetworkSyncingProps) { /** * method that will dispatch the `NetworkController:updateNetwork` action. * transforms and corrects the network configuration (and RPCs) we pass through. + * * @param props - properties * @param props.messenger - messenger to call the action * @param props.originalNetworkConfiguration - original network config (from network controller state) @@ -116,6 +117,7 @@ export const dispatchUpdateNetwork = async (props: { /** * Action to perform the main network sync. * It will fetch local networks and remote networks, then determines which networks (local and remote) to add/update + * * @param props - parameters used for this main sync */ export async function performMainNetworkSync( diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.update-network.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.update-network.test.ts index c545f1affd2..ac817a65382 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.update-network.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.update-network.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import type { NetworkState, NetworkControllerActions, @@ -9,9 +9,8 @@ import { NetworkStatus, RpcEndpointType, } from '@metamask/network-controller'; -import nock from 'nock'; +import nock, { cleanAll } from 'nock'; -import type { UserStorageControllerMessenger } from '..'; import type { RPCEndpoint } from './__fixtures__/mockNetwork'; import { createMockCustomRpcEndpoint, @@ -19,6 +18,7 @@ import { createMockNetworkConfiguration, } from './__fixtures__/mockNetwork'; import { dispatchUpdateNetwork } from './controller-integration'; +import type { UserStorageControllerMessenger } from '..'; const createNetworkControllerState = ( rpcs: RPCEndpoint[] = [createMockInfuraRpcEndpoint()], @@ -62,7 +62,7 @@ describe('network-syncing/controller-integration - dispatchUpdateNetwork()', () }); afterAll(() => { - nock.cleanAll(); + cleanAll(); }); const setupTest = ({ @@ -85,10 +85,7 @@ describe('network-syncing/controller-integration - dispatchUpdateNetwork()', () }; const arrangeNetworkController = (networkState: NetworkState) => { - const baseMessenger = new ControllerMessenger< - NetworkControllerActions, - never - >(); + const baseMessenger = new Messenger(); const networkControllerMessenger = baseMessenger.getRestricted({ name: 'NetworkController', allowedActions: [], @@ -99,6 +96,8 @@ describe('network-syncing/controller-integration - dispatchUpdateNetwork()', () messenger: networkControllerMessenger, state: networkState, infuraProjectId: 'TEST_ID', + fetch, + btoa, }); return { networkController, baseMessenger }; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/services.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/services.test.ts index a77fa2a1d9f..9165a13e583 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/services.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/services.test.ts @@ -1,3 +1,10 @@ +import { createMockRemoteNetworkConfiguration } from './__fixtures__/mockNetwork'; +import { + batchUpsertRemoteNetworks, + getAllRemoteNetworks, + upsertRemoteNetwork, +} from './services'; +import type { RemoteNetworkConfiguration } from './types'; import { USER_STORAGE_FEATURE_NAMES } from '../../../shared/storage-schema'; import { MOCK_STORAGE_KEY, @@ -9,13 +16,6 @@ import { mockEndpointUpsertUserStorage, } from '../__fixtures__/mockServices'; import type { UserStorageBaseOptions } from '../services'; -import { createMockRemoteNetworkConfiguration } from './__fixtures__/mockNetwork'; -import { - batchUpsertRemoteNetworks, - getAllRemoteNetworks, - upsertRemoteNetwork, -} from './services'; -import type { RemoteNetworkConfiguration } from './types'; const storageOpts: UserStorageBaseOptions = { bearerToken: 'MOCK_TOKEN', @@ -78,6 +78,7 @@ describe('network-syncing/services - getAllRemoteNetworks()', () => { const { mockGetAllAPI } = await arrangeMockGetAllAPI(mockNetwork); const realParse = JSON.parse; jest.spyOn(JSON, 'parse').mockImplementation((data) => { + // eslint-disable-next-line jest/no-conditional-in-test if (data === JSON.stringify(mockNetwork)) { throw new Error('MOCK FAIL TO PARSE STRING'); } diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/services.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/services.ts index 5d48ba14127..5d5fd371021 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/services.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/services.ts @@ -1,3 +1,4 @@ +import type { RemoteNetworkConfiguration } from './types'; import { USER_STORAGE_FEATURE_NAMES } from '../../../shared/storage-schema'; import type { UserStorageBaseOptions } from '../services'; import { @@ -5,11 +6,11 @@ import { getUserStorageAllFeatureEntries, upsertUserStorage, } from '../services'; -import type { RemoteNetworkConfiguration } from './types'; // TODO - parse type, and handle version changes /** * parses the raw remote data to the NetworkConfiguration shape + * * @todo - improve parsing instead of asserting * @todo - improve version handling * @param rawData - raw remote user storage data @@ -28,6 +29,7 @@ const isDefined = (value: Value | null | undefined): value is Value => /** * gets all remote networks from user storage + * * @param opts - user storage options/configuration * @returns array of all remote networks */ @@ -49,8 +51,10 @@ export async function getAllRemoteNetworks( /** * Upserts a remote network to user storage + * * @param network - network we are updating or inserting * @param opts - user storage options/configuration + * @returns void */ export async function upsertRemoteNetwork( network: RemoteNetworkConfiguration, @@ -66,6 +70,7 @@ export async function upsertRemoteNetwork( /** * Batch upsert a list of remote networks into user storage + * * @param networks - a list of networks to update or insert * @param opts - user storage options/configuration */ diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.test.ts index 7dba3649345..a986c7e5d44 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.test.ts @@ -97,9 +97,11 @@ describe('checkWhichNetworkIsLatest()', () => { 'should test when [$test] and the result would be: [$actual]', ({ dates, actual }) => { const localNetwork = createMockNetworkConfiguration({ + // eslint-disable-next-line jest/no-conditional-in-test lastUpdatedAt: dates[0] ?? undefined, }); const remoteNetwork = createMockRemoteNetworkConfiguration({ + // eslint-disable-next-line jest/no-conditional-in-test lastUpdatedAt: dates[1] ?? undefined, }); const result = checkWhichNetworkIsLatest(localNetwork, remoteNetwork); @@ -122,12 +124,14 @@ describe('getUpdatedNetworkLists()', () => { localNetworks.push( createMockNetworkConfiguration({ chainId: `0x${idx}`, + // eslint-disable-next-line jest/no-conditional-in-test lastUpdatedAt: dates[0] ?? undefined, }), ); remoteNetworks.push( createMockRemoteNetworkConfiguration({ chainId: `0x${idx}`, + // eslint-disable-next-line jest/no-conditional-in-test lastUpdatedAt: dates[1] ?? undefined, }), ); @@ -169,6 +173,7 @@ describe('getUpdatedNetworkLists()', () => { let testCount = 0; testMatrix.forEach(({ actual }, idx) => { const chainId = `0x${idx}` as const; + // eslint-disable-next-line jest/no-conditional-in-test if (actual === 'Do Nothing') { testCount += 1; // eslint-disable-next-line jest/no-conditional-expect @@ -177,10 +182,12 @@ describe('getUpdatedNetworkLists()', () => { localIdsRemoved.includes(chainId), remoteIdsUpdated.includes(chainId), ]).toStrictEqual([false, false, false]); + // eslint-disable-next-line jest/no-conditional-in-test } else if (actual === 'Local Wins') { testCount += 1; // eslint-disable-next-line jest/no-conditional-expect expect(remoteIdsUpdated).toContain(chainId); + // eslint-disable-next-line jest/no-conditional-in-test } else if (actual === 'Remote Wins') { testCount += 1; // eslint-disable-next-line jest/no-conditional-expect @@ -238,12 +245,14 @@ describe('findNetworksToUpdate()', () => { localNetworks.push( createMockNetworkConfiguration({ chainId: `0x${idx}`, + // eslint-disable-next-line jest/no-conditional-in-test lastUpdatedAt: dates[0] ?? undefined, }), ); remoteNetworks.push( createMockRemoteNetworkConfiguration({ chainId: `0x${idx}`, + // eslint-disable-next-line jest/no-conditional-in-test lastUpdatedAt: dates[1] ?? undefined, }), ); @@ -257,14 +266,17 @@ describe('findNetworksToUpdate()', () => { // Assert - Local and Remote networks to update const updateLocalIds = + // eslint-disable-next-line jest/no-conditional-in-test result?.localNetworksToUpdate?.map((n) => n.chainId) ?? []; const updateRemoteIds = + // eslint-disable-next-line jest/no-conditional-in-test result?.remoteNetworksToUpdate?.map((n) => n.chainId) ?? []; // Check Test Matrix combinations were all tested let testCount = 0; testMatrix.forEach(({ actual }, idx) => { const chainId = `0x${idx}` as const; + // eslint-disable-next-line jest/no-conditional-in-test if (actual === 'Do Nothing') { testCount += 1; // No lists are updated if nothing changes @@ -273,6 +285,7 @@ describe('findNetworksToUpdate()', () => { updateLocalIds.includes(chainId), updateRemoteIds.includes(chainId), ]).toStrictEqual([false, false]); + // eslint-disable-next-line jest/no-conditional-in-test } else if (actual === 'Local Wins') { testCount += 1; // Only remote is updated if local wins @@ -281,6 +294,7 @@ describe('findNetworksToUpdate()', () => { updateLocalIds.includes(chainId), updateRemoteIds.includes(chainId), ]).toStrictEqual([false, true]); + // eslint-disable-next-line jest/no-conditional-in-test } else if (actual === 'Remote Wins') { testCount += 1; // Only local is updated if remote wins @@ -321,6 +335,7 @@ describe('findNetworksToUpdate()', () => { /** * Test Utility - Create a list of mock local network configurations + * * @param ids - list of chains to support * @returns list of local networks */ @@ -332,6 +347,7 @@ function arrangeLocalNetworks(ids: string[]) { /** * Test Utility - Create a list of mock remote network configurations + * * @param ids - list of chains to support * @returns list of local networks */ diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.ts index 40042e21df3..d805469d0bb 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.ts @@ -1,11 +1,11 @@ import type { NetworkConfiguration } from '@metamask/network-controller'; -import { setDifference, setIntersection } from '../utils'; import { toRemoteNetworkConfiguration, type RemoteNetworkConfiguration, toNetworkConfiguration, } from './types'; +import { setDifference, setIntersection } from '../utils'; type FindNetworksToUpdateProps = { localNetworks: NetworkConfiguration[]; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.test.ts index 76e1b780c42..529e59da565 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.test.ts @@ -1,12 +1,5 @@ import type { NetworkConfiguration } from '@metamask/network-controller'; -import { USER_STORAGE_FEATURE_NAMES } from '../../../shared/storage-schema'; -import { MOCK_STORAGE_KEY } from '../__fixtures__'; -import { - mockEndpointBatchUpsertUserStorage, - mockEndpointUpsertUserStorage, -} from '../__fixtures__/mockServices'; -import type { UserStorageBaseOptions } from '../services'; import { createMockNetworkConfiguration } from './__fixtures__/mockNetwork'; import { addNetwork, @@ -14,6 +7,13 @@ import { deleteNetwork, updateNetwork, } from './sync-mutations'; +import { USER_STORAGE_FEATURE_NAMES } from '../../../shared/storage-schema'; +import { MOCK_STORAGE_KEY } from '../__fixtures__'; +import { + mockEndpointBatchUpsertUserStorage, + mockEndpointUpsertUserStorage, +} from '../__fixtures__/mockServices'; +import type { UserStorageBaseOptions } from '../services'; const storageOpts: UserStorageBaseOptions = { bearerToken: 'MOCK_TOKEN', diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.ts index f11f4e19e31..98001be122b 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.ts @@ -1,8 +1,8 @@ import type { NetworkConfiguration } from '@metamask/network-controller'; -import type { UserStorageBaseOptions } from '../services'; import { batchUpsertRemoteNetworks, upsertRemoteNetwork } from './services'; import type { RemoteNetworkConfiguration } from './types'; +import type { UserStorageBaseOptions } from '../services'; export const updateNetwork = async ( network: NetworkConfiguration, diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/update-network-utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/update-network-utils.ts index 925974872eb..be52790248c 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/update-network-utils.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/update-network-utils.ts @@ -52,6 +52,7 @@ export const getMappedNetworkConfiguration = (props: { /** * Will insert any missing infura RPCs, as we cannot remove infura RPC * Exported for testability + * * @param props - properties * @param props.originalNetworkConfiguration - original network configuration * @param props.updateNetworkConfiguration - the updated network configuration to use when dispatching `NetworkController:updateNetwork` @@ -97,6 +98,7 @@ export const appendMissingInfuraNetworks = (props: { /** * The `NetworkController:updateNetwork` method will require us to pass in a `replacementSelectedRpcEndpointIndex` if the selected RPC is removed or modified + * * @param props - properties * @param props.originalNetworkConfiguration - the original network configuration * @param props.updateNetworkConfiguration - the new network configuration we will use to update @@ -150,6 +152,7 @@ export const getNewRPCIndex = (props: { /** * create the correct `NetworkController:updateNetwork` parameters + * * @param props - properties * @param props.originalNetworkConfiguration - original config * @param props.newNetworkConfiguration - new config (from remote) diff --git a/packages/profile-sync-controller/src/controllers/user-storage/services.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/services.test.ts index ec2b698b0c6..8fc27ee6122 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/services.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/services.test.ts @@ -1,7 +1,3 @@ -import encryption, { createSHA256Hash } from '../../shared/encryption'; -import { SHARED_SALT } from '../../shared/encryption/constants'; -import type { UserStorageFeatureKeys } from '../../shared/storage-schema'; -import { USER_STORAGE_FEATURE_NAMES } from '../../shared/storage-schema'; import { createMockGetStorageResponse } from './__fixtures__'; import { mockEndpointGetUserStorage, @@ -27,6 +23,10 @@ import { deleteUserStorage, batchUpsertUserStorageWithAlreadyHashedAndEncryptedEntries, } from './services'; +import encryption, { createSHA256Hash } from '../../shared/encryption'; +import { SHARED_SALT } from '../../shared/encryption/constants'; +import { USER_STORAGE_FEATURE_NAMES } from '../../shared/storage-schema'; +import type { UserStorageFeatureKeys } from '../../shared/storage-schema'; describe('user-storage/services.ts - getUserStorage() tests', () => { const actCallGetUserStorage = async () => { @@ -106,6 +106,7 @@ describe('user-storage/services.ts - getUserStorage() tests', () => { `${USER_STORAGE_FEATURE_NAMES.notifications}.notification_settings`, undefined, async (requestBody) => { + // eslint-disable-next-line jest/no-conditional-in-test if (typeof requestBody === 'string') { return; } @@ -175,6 +176,7 @@ describe('user-storage/services.ts - getUserStorageAllFeatureEntries() tests', ( USER_STORAGE_FEATURE_NAMES.notifications, undefined, async (_uri, requestBody) => { + // eslint-disable-next-line jest/no-conditional-in-test if (typeof requestBody === 'string') { return; } @@ -270,6 +272,7 @@ describe('user-storage/services.ts - upsertUserStorage() tests', () => { `${USER_STORAGE_FEATURE_NAMES.notifications}.notification_settings`, undefined, async (requestBody) => { + // eslint-disable-next-line jest/no-conditional-in-test if (typeof requestBody === 'string') { return; } @@ -323,6 +326,7 @@ describe('user-storage/services.ts - batchUpsertUserStorage() tests', () => { USER_STORAGE_FEATURE_NAMES.accounts, undefined, async (_uri, requestBody) => { + // eslint-disable-next-line jest/no-conditional-in-test if (typeof requestBody === 'string') { return; } @@ -410,6 +414,7 @@ describe('user-storage/services.ts - batchUpsertUserStorageWithAlreadyHashedAndE USER_STORAGE_FEATURE_NAMES.accounts, undefined, async (_uri, requestBody) => { + // eslint-disable-next-line jest/no-conditional-in-test if (typeof requestBody === 'string') { return; } @@ -592,6 +597,7 @@ describe('user-storage/services.ts - batchDeleteUserStorage() tests', () => { USER_STORAGE_FEATURE_NAMES.accounts, undefined, async (_uri, requestBody) => { + // eslint-disable-next-line jest/no-conditional-in-test if (typeof requestBody === 'string') { return; } diff --git a/packages/profile-sync-controller/src/controllers/user-storage/services.ts b/packages/profile-sync-controller/src/controllers/user-storage/services.ts index 1ccf1fd10cb..6d239b80e4a 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/services.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/services.ts @@ -19,16 +19,12 @@ export const USER_STORAGE_ENDPOINT = `${USER_STORAGE_API}/api/v1/userstorage`; * This is the Server Response shape for a feature entry. */ export type GetUserStorageResponse = { - // eslint-disable-next-line @typescript-eslint/naming-convention HashedKey: string; - // eslint-disable-next-line @typescript-eslint/naming-convention Data: string; }; export type GetUserStorageAllFeatureEntriesResponse = { - // eslint-disable-next-line @typescript-eslint/naming-convention HashedKey: string; - // eslint-disable-next-line @typescript-eslint/naming-convention Data: string; }[]; @@ -379,7 +375,7 @@ export async function batchDeleteUserStorage( 'Content-Type': 'application/json', Authorization: `Bearer ${bearerToken}`, }, - // eslint-disable-next-line @typescript-eslint/naming-convention + body: JSON.stringify({ batch_delete: encryptedData }), }); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/utils.ts index e57e317b631..3710d1a3f4c 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/utils.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/utils.ts @@ -1,6 +1,7 @@ /** * Returns the difference between 2 sets. * NOTE - this is temporary until we can support Set().difference method + * * @param a - First Set * @param b - Second Set * @returns The difference between the first and second set. @@ -14,6 +15,7 @@ export function setDifference(a: Set, b: Set): Set { /** * Returns the intersection between 2 sets. * NOTE - this is temporary until we can support Set().intersection method + * * @param a - First Set * @param b - Second Set * @returns The intersection between the first and second set. diff --git a/packages/profile-sync-controller/src/sdk/__fixtures__/mock-auth.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/mock-auth.ts index b5a201bbead..1d519b6864a 100644 --- a/packages/profile-sync-controller/src/sdk/__fixtures__/mock-auth.ts +++ b/packages/profile-sync-controller/src/sdk/__fixtures__/mock-auth.ts @@ -30,31 +30,31 @@ const MOCK_NONCE_RESPONSE = { nonce: 'xGMm9SoihEKeAEfV', identifier: '0xd8641601Cb79a94FD872fE42d5b4a067A44a7e88', // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + expires_in: 300, }; const MOCK_SIWE_LOGIN_RESPONSE = { token: MOCK_JWT, // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + expires_in: 3600, profile: { // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + profile_id: 'fa2bbf82-bd9a-4e6b-aabc-9ca0d0319b6e', // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + metametrics_id: 'de742679-4960-4977-a415-4718b5f8e86c', // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + identifier_id: 'ec9a4e9906836497efad2fd4d4290b34d2c6a2c0d93eb174aa3cd88a133adbaf', // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + identifier_type: 'SIWE', // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + encrypted_storage_key: '2c6a2c0d93eb174aa3cd88a133adbaf', }, }; @@ -62,34 +62,34 @@ const MOCK_SIWE_LOGIN_RESPONSE = { export const MOCK_SRP_LOGIN_RESPONSE = { token: MOCK_JWT, // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + expires_in: 3600, profile: { // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + profile_id: 'f88227bd-b615-41a3-b0be-467dd781a4ad', // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + metametrics_id: '561ec651-a844-4b36-a451-04d6eac35740', // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + identifier_id: 'da9a9fc7b09edde9cc23cec9b7e11a71fb0ab4d2ddd8af8af905306f3e1456fb', // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + identifier_type: 'SRP', // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + encrypted_storage_key: 'd2ddd8af8af905306f3e1456fb', }, }; export const MOCK_OIDC_TOKEN_RESPONSE = { // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + access_token: MOCK_ACCESS_JWT, // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + expires_in: 3600, }; diff --git a/packages/profile-sync-controller/src/sdk/__fixtures__/mock-userstorage.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/mock-userstorage.ts index 4b7a8fbe44e..eb70b1c1f88 100644 --- a/packages/profile-sync-controller/src/sdk/__fixtures__/mock-userstorage.ts +++ b/packages/profile-sync-controller/src/sdk/__fixtures__/mock-userstorage.ts @@ -21,8 +21,6 @@ const MOCK_STORAGE_URL_ALL_FEATURE_ENTRIES = STORAGE_URL( ); export const MOCK_STORAGE_KEY = createSHA256Hash('mockStorageKey'); -// TODO: Either fix this lint violation or explain why it's necessary to ignore. -// eslint-disable-next-line @typescript-eslint/naming-convention export const MOCK_NOTIFICATIONS_DATA = '{ is_compact: false }'; export const MOCK_NOTIFICATIONS_DATA_ENCRYPTED = async (data?: string) => await encryption.encryptString( diff --git a/packages/profile-sync-controller/src/sdk/__fixtures__/test-utils.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/test-utils.ts index 421fa6b3618..263b819b448 100644 --- a/packages/profile-sync-controller/src/sdk/__fixtures__/test-utils.ts +++ b/packages/profile-sync-controller/src/sdk/__fixtures__/test-utils.ts @@ -15,7 +15,7 @@ export type MockVariable = any; // Utility for mocking, the generics will constrain values // TODO: Either fix this lint violation or explain why it's necessary to ignore. -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/naming-convention +// eslint-disable-next-line @typescript-eslint/no-explicit-any export const typedMockFn = any>() => jest.fn, Parameters>(); @@ -112,6 +112,7 @@ export function arrangeAuth( /** * Mock utility - creates a mock provider + * * @returns mock provider */ export const arrangeMockProvider = () => { diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-siwe.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-siwe.ts index 56367d6e661..91cff8a35ee 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-siwe.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-siwe.ts @@ -1,7 +1,5 @@ import { SiweMessage } from 'siwe'; -import { ValidationError } from '../errors'; -import { validateLoginResponse } from '../utils/validate-login-response'; import { SIWE_LOGIN_URL, authenticate, @@ -16,15 +14,17 @@ import type { LoginResponse, UserProfile, } from './types'; +import { ValidationError } from '../errors'; +import { validateLoginResponse } from '../utils/validate-login-response'; // TODO: Either fix this lint violation or explain why it's necessary to ignore. -// eslint-disable-next-line @typescript-eslint/naming-convention + type JwtBearerAuth_SIWE_Options = { storage: AuthStorageOptions; }; // TODO: Either fix this lint violation or explain why it's necessary to ignore. -// eslint-disable-next-line @typescript-eslint/naming-convention + type JwtBearerAuth_SIWE_Signer = { address: string; chainId: number; @@ -33,9 +33,9 @@ type JwtBearerAuth_SIWE_Signer = { }; export class SIWEJwtBearerAuth implements IBaseAuth { - #config: AuthConfig; + readonly #config: AuthConfig; - #options: JwtBearerAuth_SIWE_Options; + readonly #options: JwtBearerAuth_SIWE_Options; #signer: JwtBearerAuth_SIWE_Signer | undefined; diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts index 652acea2459..d67fbac50e0 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts @@ -1,13 +1,5 @@ import type { Eip1193Provider } from 'ethers'; -import { ValidationError } from '../errors'; -import { getMetaMaskProviderEIP6963 } from '../utils/eip-6963-metamask-provider'; -import { - MESSAGE_SIGNING_SNAP, - connectSnap, - isSnapConnected, -} from '../utils/messaging-signing-snap-requests'; -import { validateLoginResponse } from '../utils/validate-login-response'; import { authenticate, authorizeOIDC, getNonce } from './services'; import type { AuthConfig, @@ -18,9 +10,17 @@ import type { LoginResponse, UserProfile, } from './types'; +import { ValidationError } from '../errors'; +import { getMetaMaskProviderEIP6963 } from '../utils/eip-6963-metamask-provider'; +import { + MESSAGE_SIGNING_SNAP, + connectSnap, + isSnapConnected, +} from '../utils/messaging-signing-snap-requests'; +import { validateLoginResponse } from '../utils/validate-login-response'; // TODO: Either fix this lint violation or explain why it's necessary to ignore. -// eslint-disable-next-line @typescript-eslint/naming-convention + type JwtBearerAuth_SRP_Options = { storage: AuthStorageOptions; signing?: AuthSigningOptions; @@ -52,9 +52,9 @@ const getDefaultEIP6963SigningOptions = ( }); export class SRPJwtBearerAuth implements IBaseAuth { - #config: AuthConfig; + readonly #config: AuthConfig; - #options: Required; + readonly #options: Required; #customProvider?: Eip1193Provider; diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.ts index 7eec0ad90f0..c9e58ab58f5 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.ts @@ -1,3 +1,5 @@ +import type { AccessToken, ErrorMessage, UserProfile } from './types'; +import { AuthType } from './types'; import type { Env, Platform } from '../../shared/env'; import { getEnvUrls, getOidcClientId } from '../../shared/env'; import { @@ -6,8 +8,6 @@ import { SignInError, ValidationError, } from '../errors'; -import type { AccessToken, ErrorMessage, UserProfile } from './types'; -import { AuthType } from './types'; export const NONCE_URL = (env: Env) => `${getEnvUrls(env).authApiUrl}/api/v2/nonce`; @@ -47,13 +47,13 @@ type NonceResponse = { type PairRequest = { signature: string; // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + raw_message: string; // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + encrypted_storage_key: string; // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + identifier_type: 'SIWE' | 'SRP'; }; @@ -168,7 +168,7 @@ export async function authorizeOIDC( if (!response.ok) { const responseBody = (await response.json()) as { // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + error_description: string; error: string; }; @@ -222,7 +222,7 @@ export async function authenticate( body: JSON.stringify({ signature, // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + raw_message: rawMessage, }), }); diff --git a/packages/profile-sync-controller/src/sdk/authentication.test.ts b/packages/profile-sync-controller/src/sdk/authentication.test.ts index d902ccf04a8..53b2119453b 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.test.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.test.ts @@ -1,4 +1,3 @@ -import { Env, Platform } from '../shared/env'; import { MOCK_ACCESS_JWT, MOCK_SRP_LOGIN_RESPONSE, @@ -16,6 +15,7 @@ import { ValidationError, } from './errors'; import * as Eip6963MetamaskProvider from './utils/eip-6963-metamask-provider'; +import { Env, Platform } from '../shared/env'; const MOCK_SRP = '0x6265617665726275696c642e6f7267'; const MOCK_ADDRESS = '0x68757d15a4d8d1421c17003512AFce15D3f3FaDa'; @@ -86,7 +86,7 @@ describe('Identifier Pairing', () => { '0xc89a614e873c2c1f08fc8d72590e13c961ea856cc7a9cd08af4bf3d3fca11111', identifierType: 'SRP', signMessage: async (message: string): Promise => { - return new Promise((_, reject) => { + return new Promise((_resolve, reject) => { reject(new Error(`unable to sign message: ${message}`)); }); }, @@ -246,7 +246,7 @@ describe('Authentication - SRP Flow - getAccessToken() & getUserProfile()', () = status: 400, body: { // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + error_description: 'invalid JWT token', error: 'invalid_request', }, @@ -423,7 +423,7 @@ describe('Authentication - SIWE Flow - getAccessToken(), getUserProfile(), signM status: 400, body: { // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + error_description: 'invalid JWT token', error: 'invalid_request', }, diff --git a/packages/profile-sync-controller/src/sdk/authentication.ts b/packages/profile-sync-controller/src/sdk/authentication.ts index 36d4c23e7a6..b696cc53d00 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.ts @@ -1,6 +1,5 @@ import type { Eip1193Provider } from 'ethers'; -import type { Env } from '../shared/env'; import { SIWEJwtBearerAuth } from './authentication-jwt-bearer/flow-siwe'; import { SRPJwtBearerAuth } from './authentication-jwt-bearer/flow-srp'; import { @@ -10,10 +9,11 @@ import { import type { UserProfile, Pair } from './authentication-jwt-bearer/types'; import { AuthType } from './authentication-jwt-bearer/types'; import { PairError, UnsupportedAuthTypeError } from './errors'; +import type { Env } from '../shared/env'; // Computing the Classes, so we only get back the public methods for the interface. // TODO: Either fix this lint violation or explain why it's necessary to ignore. -// eslint-disable-next-line @typescript-eslint/naming-convention + type Compute = T extends infer U ? { [K in keyof U]: U[K] } : never; type SIWEInterface = Compute; type SRPInterface = Compute; @@ -23,11 +23,11 @@ type SRPParams = ConstructorParameters; type JwtBearerAuthParams = SiweParams | SRPParams; export class JwtBearerAuth implements SIWEInterface, SRPInterface { - #type: AuthType; + readonly #type: AuthType; - #env: Env; + readonly #env: Env; - #sdk: SIWEJwtBearerAuth | SRPJwtBearerAuth; + readonly #sdk: SIWEJwtBearerAuth | SRPJwtBearerAuth; constructor(...args: JwtBearerAuthParams) { this.#type = args[0].type; @@ -89,13 +89,13 @@ export class JwtBearerAuth implements SIWEInterface, SRPInterface { return { signature: sig, // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + raw_message: raw, // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + encrypted_storage_key: p.encryptedStorageKey, // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + identifier_type: p.identifierType, }; } catch (e) { 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 fb2c7dbb358..75803c946f1 100644 --- a/packages/profile-sync-controller/src/sdk/user-storage.test.ts +++ b/packages/profile-sync-controller/src/sdk/user-storage.test.ts @@ -1,8 +1,3 @@ -import encryption, { createSHA256Hash } from '../shared/encryption'; -import { SHARED_SALT } from '../shared/encryption/constants'; -import { Env } from '../shared/env'; -import type { UserStorageFeatureKeys } from '../shared/storage-schema'; -import { USER_STORAGE_FEATURE_NAMES } from '../shared/storage-schema'; import { arrangeAuthAPIs } from './__fixtures__/mock-auth'; import { MOCK_NOTIFICATIONS_DATA, @@ -20,6 +15,11 @@ import type { IBaseAuth } from './authentication-jwt-bearer/types'; import { NotFoundError, UserStorageError } from './errors'; import type { StorageOptions } from './user-storage'; import { STORAGE_URL, UserStorage } from './user-storage'; +import encryption, { createSHA256Hash } from '../shared/encryption'; +import { SHARED_SALT } from '../shared/encryption/constants'; +import { Env } from '../shared/env'; +import { USER_STORAGE_FEATURE_NAMES } from '../shared/storage-schema'; +import type { UserStorageFeatureKeys } from '../shared/storage-schema'; const MOCK_SRP = '0x6265617665726275696c642e6f7267'; const MOCK_ADDRESS = '0x68757d15a4d8d1421c17003512AFce15D3f3FaDa'; @@ -107,6 +107,7 @@ describe('User Storage', () => { const mockPut = handleMockUserStoragePut( undefined, async (_, requestBody) => { + // eslint-disable-next-line jest/no-conditional-in-test if (typeof requestBody === 'string') { return; } @@ -167,6 +168,7 @@ describe('User Storage', () => { const mockPut = handleMockUserStoragePut( undefined, async (_, requestBody) => { + // eslint-disable-next-line jest/no-conditional-in-test if (typeof requestBody === 'string') { return; } @@ -215,6 +217,7 @@ describe('User Storage', () => { const mockPut = handleMockUserStoragePut( undefined, async (_, requestBody) => { + // eslint-disable-next-line jest/no-conditional-in-test if (typeof requestBody === 'string') { return; } @@ -318,6 +321,7 @@ describe('User Storage', () => { const mockPut = handleMockUserStorageBatchDelete( undefined, async (_, requestBody) => { + // eslint-disable-next-line jest/no-conditional-in-test if (typeof requestBody === 'string') { return; } diff --git a/packages/profile-sync-controller/src/sdk/user-storage.ts b/packages/profile-sync-controller/src/sdk/user-storage.ts index ad2c52e39fb..536ecaa9d1d 100644 --- a/packages/profile-sync-controller/src/sdk/user-storage.ts +++ b/packages/profile-sync-controller/src/sdk/user-storage.ts @@ -1,16 +1,16 @@ +import type { IBaseAuth } from './authentication-jwt-bearer/types'; +import { NotFoundError, UserStorageError } from './errors'; import encryption, { createSHA256Hash } from '../shared/encryption'; import { SHARED_SALT } from '../shared/encryption/constants'; import type { Env } from '../shared/env'; import { getEnvUrls } from '../shared/env'; import type { - UserStorageFeatureKeys, - UserStorageFeatureNames, - UserStoragePathWithFeatureAndKey, - UserStoragePathWithFeatureOnly, + UserStorageGenericFeatureKey, + UserStorageGenericFeatureName, + UserStorageGenericPathWithFeatureAndKey, + UserStorageGenericPathWithFeatureOnly, } from '../shared/storage-schema'; import { createEntryPath } from '../shared/storage-schema'; -import type { IBaseAuth } from './authentication-jwt-bearer/types'; -import { NotFoundError, UserStorageError } from './errors'; export const STORAGE_URL = (env: Env, encryptedPath: string) => `${getEnvUrls(env).userStorageApiUrl}/api/v1/userstorage/${encryptedPath}`; @@ -30,9 +30,8 @@ export type UserStorageOptions = { }; export type GetUserStorageAllFeatureEntriesResponse = { - // eslint-disable-next-line @typescript-eslint/naming-convention HashedKey: string; - // eslint-disable-next-line @typescript-eslint/naming-convention + Data: string; }[]; @@ -55,42 +54,46 @@ export class UserStorage { } async setItem( - path: UserStoragePathWithFeatureAndKey, + path: UserStorageGenericPathWithFeatureAndKey, value: string, ): Promise { await this.#upsertUserStorage(path, value); } - async batchSetItems( - path: FeatureName, - values: [UserStorageFeatureKeys, string][], + async batchSetItems( + path: UserStorageGenericFeatureName, + values: [UserStorageGenericFeatureKey, string][], ) { await this.#batchUpsertUserStorage(path, values); } - async getItem(path: UserStoragePathWithFeatureAndKey): Promise { + async getItem( + path: UserStorageGenericPathWithFeatureAndKey, + ): Promise { return this.#getUserStorage(path); } async getAllFeatureItems( - path: UserStoragePathWithFeatureOnly, + path: UserStorageGenericFeatureName, ): Promise { return this.#getUserStorageAllFeatureEntries(path); } - async deleteItem(path: UserStoragePathWithFeatureAndKey): Promise { + async deleteItem( + path: UserStorageGenericPathWithFeatureAndKey, + ): Promise { return this.#deleteUserStorage(path); } async deleteAllFeatureItems( - path: UserStoragePathWithFeatureOnly, + path: UserStorageGenericFeatureName, ): Promise { return this.#deleteUserStorageAllFeatureEntries(path); } async batchDeleteItems( - path: UserStoragePathWithFeatureOnly, - values: string[], + path: UserStorageGenericFeatureName, + values: UserStorageGenericFeatureKey[], ) { return this.#batchDeleteUserStorage(path, values); } @@ -111,14 +114,16 @@ export class UserStorage { } async #upsertUserStorage( - path: UserStoragePathWithFeatureAndKey, + path: UserStorageGenericPathWithFeatureAndKey, data: string, ): Promise { try { const headers = await this.#getAuthorizationHeader(); const storageKey = await this.getStorageKey(); const encryptedData = await encryption.encryptString(data, storageKey); - const encryptedPath = createEntryPath(path, storageKey); + const encryptedPath = createEntryPath(path, storageKey, { + validateAgainstSchema: false, + }); const url = new URL(STORAGE_URL(this.env, encryptedPath)); @@ -151,7 +156,7 @@ export class UserStorage { } async #batchUpsertUserStorage( - path: UserStoragePathWithFeatureOnly, + path: UserStorageGenericPathWithFeatureOnly, data: [string, string][], ): Promise { try { @@ -202,7 +207,7 @@ export class UserStorage { } async #batchUpsertUserStorageWithAlreadyHashedAndEncryptedEntries( - path: UserStoragePathWithFeatureOnly, + path: UserStorageGenericPathWithFeatureOnly, encryptedData: [string, string][], ): Promise { try { @@ -243,12 +248,14 @@ export class UserStorage { } async #getUserStorage( - path: UserStoragePathWithFeatureAndKey, + path: UserStorageGenericPathWithFeatureAndKey, ): Promise { try { const headers = await this.#getAuthorizationHeader(); const storageKey = await this.getStorageKey(); - const encryptedPath = createEntryPath(path, storageKey); + const encryptedPath = createEntryPath(path, storageKey, { + validateAgainstSchema: false, + }); const url = new URL(STORAGE_URL(this.env, encryptedPath)); @@ -301,7 +308,7 @@ export class UserStorage { } async #getUserStorageAllFeatureEntries( - path: UserStoragePathWithFeatureOnly, + path: UserStorageGenericPathWithFeatureOnly, ): Promise { try { const headers = await this.#getAuthorizationHeader(); @@ -384,12 +391,14 @@ export class UserStorage { } async #deleteUserStorage( - path: UserStoragePathWithFeatureAndKey, + path: UserStorageGenericPathWithFeatureAndKey, ): Promise { try { const headers = await this.#getAuthorizationHeader(); const storageKey = await this.getStorageKey(); - const encryptedPath = createEntryPath(path, storageKey); + const encryptedPath = createEntryPath(path, storageKey, { + validateAgainstSchema: false, + }); const url = new URL(STORAGE_URL(this.env, encryptedPath)); @@ -429,7 +438,7 @@ export class UserStorage { } async #deleteUserStorageAllFeatureEntries( - path: UserStoragePathWithFeatureOnly, + path: UserStorageGenericPathWithFeatureOnly, ): Promise { try { const headers = await this.#getAuthorizationHeader(); @@ -470,7 +479,7 @@ export class UserStorage { } async #batchDeleteUserStorage( - path: UserStoragePathWithFeatureOnly, + path: UserStorageGenericPathWithFeatureOnly, data: string[], ): Promise { try { @@ -493,7 +502,7 @@ export class UserStorage { 'Content-Type': 'application/json', ...headers, }, - // eslint-disable-next-line @typescript-eslint/naming-convention + body: JSON.stringify({ batch_delete: encryptedData }), }); @@ -522,7 +531,7 @@ export class UserStorage { } // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + async #getAuthorizationHeader(): Promise<{ Authorization: string }> { const accessToken = await this.config.auth.getAccessToken(); return { Authorization: `Bearer ${accessToken}` }; diff --git a/packages/profile-sync-controller/src/sdk/utils/eip-6963-metamask-provider.test.ts b/packages/profile-sync-controller/src/sdk/utils/eip-6963-metamask-provider.test.ts index f136ca56704..e1c2ca15acd 100644 --- a/packages/profile-sync-controller/src/sdk/utils/eip-6963-metamask-provider.test.ts +++ b/packages/profile-sync-controller/src/sdk/utils/eip-6963-metamask-provider.test.ts @@ -1,11 +1,11 @@ import type { Eip1193Provider } from 'ethers'; -import type { MockVariable } from '../__fixtures__/test-utils'; import type { AnnounceProviderEvent } from './eip-6963-metamask-provider'; import { getMetaMaskProviderEIP6963, metamaskClientsRdns, } from './eip-6963-metamask-provider'; +import type { MockVariable } from '../__fixtures__/test-utils'; describe('getMetaMaskProviderEIP6963() tests', () => { let unsubscribe: undefined | (() => void); @@ -16,6 +16,7 @@ describe('getMetaMaskProviderEIP6963() tests', () => { /** * Mock Utility to create and emit EIP event + * * @param rdns - mock rdns for provider * @returns an unsubscribe event listener */ @@ -24,6 +25,7 @@ describe('getMetaMaskProviderEIP6963() tests', () => { request: jest.fn(), }; + // eslint-disable-next-line n/no-unsupported-features/node-builtins const announceEvent: AnnounceProviderEvent = new CustomEvent( 'eip6963:announceProvider', { diff --git a/packages/profile-sync-controller/src/sdk/utils/eip-6963-metamask-provider.ts b/packages/profile-sync-controller/src/sdk/utils/eip-6963-metamask-provider.ts index f9fb1080849..c58e9c6ef38 100644 --- a/packages/profile-sync-controller/src/sdk/utils/eip-6963-metamask-provider.ts +++ b/packages/profile-sync-controller/src/sdk/utils/eip-6963-metamask-provider.ts @@ -30,15 +30,15 @@ const providerCache: Partial> = {}; export function getMetaMaskProviderEIP6963( type: MetaMaskClientType = 'any', ): Promise { - return new Promise((res) => { + return new Promise((resolve) => { if (type !== 'any' && metamaskClientsRdns[type] === undefined) { - res(null); + resolve(null); return; } const cachedProvider = providerCache[type]; if (cachedProvider) { - res(cachedProvider); + resolve(cachedProvider); return; } @@ -85,7 +85,7 @@ export function getMetaMaskProviderEIP6963( if (provider) { providerCache[type] = provider; } - return res(provider); + return resolve(provider); }, 100); }); } 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 7bebccbb9da..3a7c188d2fd 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 @@ -1,7 +1,3 @@ -import { - arrangeMockProvider, - type MockVariable, -} from '../__fixtures__/test-utils'; import type { Snap } from './messaging-signing-snap-requests'; import { MESSAGE_SIGNING_SNAP, @@ -10,6 +6,10 @@ import { getSnaps, isSnapConnected, } from './messaging-signing-snap-requests'; +import { + arrangeMockProvider, + type MockVariable, +} from '../__fixtures__/test-utils'; /** * Most of these utilities are wrappers around making wallet requests, diff --git a/packages/profile-sync-controller/src/sdk/utils/validate-login-response.test.ts b/packages/profile-sync-controller/src/sdk/utils/validate-login-response.test.ts index 48328db4df7..b38d5c2ab5b 100644 --- a/packages/profile-sync-controller/src/sdk/utils/validate-login-response.test.ts +++ b/packages/profile-sync-controller/src/sdk/utils/validate-login-response.test.ts @@ -1,5 +1,5 @@ -import type { LoginResponse } from '../authentication'; import { validateLoginResponse } from './validate-login-response'; +import type { LoginResponse } from '../authentication'; describe('validateLoginResponse() tests', () => { it('validates if a shape is of type LoginResponse', () => { diff --git a/packages/profile-sync-controller/src/shared/encryption/constants.ts b/packages/profile-sync-controller/src/shared/encryption/constants.ts index b39d1aa8a9c..e5a04f74783 100644 --- a/packages/profile-sync-controller/src/shared/encryption/constants.ts +++ b/packages/profile-sync-controller/src/shared/encryption/constants.ts @@ -6,9 +6,7 @@ export const ALGORITHM_KEY_SIZE = 16; // 16 bytes // see: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#scrypt export const SCRYPT_SALT_SIZE = 16; // 16 bytes export const SCRYPT_N = 2 ** 17; // CPU/memory cost parameter (must be a power of 2, > 1) -// eslint-disable-next-line @typescript-eslint/naming-convention export const SCRYPT_r = 8; // Block size parameter -// eslint-disable-next-line @typescript-eslint/naming-convention export const SCRYPT_p = 1; // Parallelization parameter export const SHARED_SALT = new Uint8Array([ diff --git a/packages/profile-sync-controller/src/shared/encryption/encryption.ts b/packages/profile-sync-controller/src/shared/encryption/encryption.ts index 7b36a15e64c..b69615c7662 100644 --- a/packages/profile-sync-controller/src/shared/encryption/encryption.ts +++ b/packages/profile-sync-controller/src/shared/encryption/encryption.ts @@ -4,7 +4,6 @@ import { scryptAsync } from '@noble/hashes/scrypt'; import { sha256 } from '@noble/hashes/sha256'; import { utf8ToBytes, concatBytes, bytesToHex } from '@noble/hashes/utils'; -import type { NativeScrypt } from '../types/encryption'; import { getCachedKeyBySalt, getCachedKeyGeneratedWithSharedSalt, @@ -25,6 +24,7 @@ import { bytesToUtf8, stringToByteArray, } from './utils'; +import type { NativeScrypt } from '../types/encryption'; export type EncryptedPayload = { // version @@ -38,7 +38,6 @@ export type EncryptedPayload = { // encryption options - scrypt o: { - // eslint-disable-next-line @typescript-eslint/naming-convention N: number; r: number; p: number; @@ -287,6 +286,7 @@ export default encryption; /** * Receive a SHA256 hash from a given string + * * @param data - input * @returns sha256 hash */ diff --git a/packages/profile-sync-controller/src/shared/env.test.ts b/packages/profile-sync-controller/src/shared/env.test.ts index 4f9e1403abd..24b5421aba0 100644 --- a/packages/profile-sync-controller/src/shared/env.test.ts +++ b/packages/profile-sync-controller/src/shared/env.test.ts @@ -1,5 +1,5 @@ -import type { MockVariable } from '../sdk/__fixtures__/test-utils'; import { getEnvUrls, Env, Platform, getOidcClientId } from './env'; +import type { MockVariable } from '../sdk/__fixtures__/test-utils'; describe('getEnvUrls', () => { it('should return URLs if given a valid environment', () => { diff --git a/packages/profile-sync-controller/src/shared/storage-schema.test.ts b/packages/profile-sync-controller/src/shared/storage-schema.test.ts index 95e096779e5..e5bcd4459f0 100644 --- a/packages/profile-sync-controller/src/shared/storage-schema.test.ts +++ b/packages/profile-sync-controller/src/shared/storage-schema.test.ts @@ -49,6 +49,15 @@ describe('user-storage/schema.ts', () => { ); }); + it('should not throw errors if validateAgainstSchema is false', () => { + const path = 'invalid.feature'; + expect(() => + getFeatureAndKeyFromPath(path, { + validateAgainstSchema: false, + }), + ).not.toThrow(); + }); + it('should return feature and key from path', () => { const result = getFeatureAndKeyFromPath( `${USER_STORAGE_FEATURE_NAMES.notifications}.notification_settings`, @@ -68,5 +77,15 @@ describe('user-storage/schema.ts', () => { key: '0x123', }); }); + + it('should return feature and key from path with arbitrary feature and key when validateAgainstSchema is false', () => { + const result = getFeatureAndKeyFromPath('feature.key', { + validateAgainstSchema: false, + }); + expect(result).toStrictEqual({ + feature: 'feature', + key: 'key', + }); + }); }); }); diff --git a/packages/profile-sync-controller/src/shared/storage-schema.ts b/packages/profile-sync-controller/src/shared/storage-schema.ts index 5ebc2a2c732..51fe4b3f8fc 100644 --- a/packages/profile-sync-controller/src/shared/storage-schema.ts +++ b/packages/profile-sync-controller/src/shared/storage-schema.ts @@ -41,9 +41,36 @@ export type UserStoragePathWithFeatureAndKey = { [K in UserStorageFeatureNames]: `${K}.${UserStorageFeatureKeys}`; }[UserStoragePathWithFeatureOnly]; -export const getFeatureAndKeyFromPath = ( - path: UserStoragePathWithFeatureAndKey, -): UserStorageFeatureAndKey => { +/** + * The below types are mainly used for the SDK. + * These exist so that the SDK can be used with arbitrary feature names and keys. + * + * We only type enforce feature names and keys when using UserStorageController. + * This is done so we don't end up with magic strings within the applications. + */ + +export type UserStorageGenericFeatureName = string; +export type UserStorageGenericFeatureKey = string; +export type UserStorageGenericPathWithFeatureAndKey = + `${UserStorageGenericFeatureName}.${UserStorageGenericFeatureKey}`; +export type UserStorageGenericPathWithFeatureOnly = + UserStorageGenericFeatureName; + +type UserStorageGenericFeatureAndKey = { + feature: UserStorageGenericFeatureName; + key: UserStorageGenericFeatureKey; +}; + +export const getFeatureAndKeyFromPath = ( + path: T extends true + ? UserStoragePathWithFeatureAndKey + : UserStorageGenericPathWithFeatureAndKey, + options: { + validateAgainstSchema: T; + } = { validateAgainstSchema: true as T }, +): T extends true + ? UserStorageFeatureAndKey + : UserStorageGenericFeatureAndKey => { const pathRegex = /^\w+\.\w+$/u; if (!pathRegex.test(path)) { @@ -52,29 +79,41 @@ export const getFeatureAndKeyFromPath = ( ); } - const [feature, key] = path.split('.') as [ - UserStorageFeatureNames, - UserStorageFeatureKeys, - ]; - - if (!(feature in USER_STORAGE_SCHEMA)) { - throw new Error(`user-storage - invalid feature provided: ${feature}`); - } - - const validFeature = USER_STORAGE_SCHEMA[feature] as readonly string[]; - - if ( - !validFeature.includes(key) && - !validFeature.includes(ALLOW_ARBITRARY_KEYS) - ) { - const validKeys = USER_STORAGE_SCHEMA[feature].join(', '); - - throw new Error( - `user-storage - invalid key provided for this feature: ${key}. Valid keys: ${validKeys}`, - ); + const [feature, key] = path.split('.'); + + if (options.validateAgainstSchema) { + const featureToValidate = feature as UserStorageFeatureNames; + const keyToValidate = key as UserStorageFeatureKeys< + typeof featureToValidate + >; + + if (!(featureToValidate in USER_STORAGE_SCHEMA)) { + throw new Error( + `user-storage - invalid feature provided: ${featureToValidate}. Valid features: ${Object.keys( + USER_STORAGE_SCHEMA, + ).join(', ')}`, + ); + } + + const validFeature = USER_STORAGE_SCHEMA[ + featureToValidate + ] as readonly string[]; + + if ( + !validFeature.includes(keyToValidate) && + !validFeature.includes(ALLOW_ARBITRARY_KEYS) + ) { + const validKeys = USER_STORAGE_SCHEMA[featureToValidate].join(', '); + + throw new Error( + `user-storage - invalid key provided for this feature: ${keyToValidate}. Valid keys: ${validKeys}`, + ); + } } - return { feature, key }; + return { feature, key } as T extends true + ? UserStorageFeatureAndKey + : UserStorageGenericFeatureAndKey; }; export const isPathWithFeatureAndKey = ( @@ -92,13 +131,21 @@ export const isPathWithFeatureAndKey = ( * * @param path - string in the form of `${feature}.${key}` that matches schema * @param storageKey - users storage key + * @param options - options object + * @param options.validateAgainstSchema - whether to validate the path against the schema. + * This defaults to true, and should only be set to false when using the SDK with arbitrary feature names and keys. * @returns path to store entry */ -export function createEntryPath( - path: UserStoragePathWithFeatureAndKey, +export function createEntryPath( + path: T extends true + ? UserStoragePathWithFeatureAndKey + : UserStorageGenericPathWithFeatureAndKey, storageKey: string, + options: { + validateAgainstSchema: T; + } = { validateAgainstSchema: true as T }, ): string { - const { feature, key } = getFeatureAndKeyFromPath(path); + const { feature, key } = getFeatureAndKeyFromPath(path, options); const hashedKey = createSHA256Hash(key + storageKey); return `${feature}/${hashedKey}`; diff --git a/packages/queued-request-controller/CHANGELOG.md b/packages/queued-request-controller/CHANGELOG.md index 3ee3ba77404..ac4b33133f4 100644 --- a/packages/queued-request-controller/CHANGELOG.md +++ b/packages/queued-request-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [9.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.5` to `^11.5.0` ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + ## [9.0.0] ### Added @@ -337,7 +345,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/queued-request-controller@9.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/queued-request-controller@9.0.1...HEAD +[9.0.1]: https://github.com/MetaMask/core/compare/@metamask/queued-request-controller@9.0.0...@metamask/queued-request-controller@9.0.1 [9.0.0]: https://github.com/MetaMask/core/compare/@metamask/queued-request-controller@8.0.2...@metamask/queued-request-controller@9.0.0 [8.0.2]: https://github.com/MetaMask/core/compare/@metamask/queued-request-controller@8.0.1...@metamask/queued-request-controller@8.0.2 [8.0.1]: https://github.com/MetaMask/core/compare/@metamask/queued-request-controller@8.0.0...@metamask/queued-request-controller@8.0.1 diff --git a/packages/queued-request-controller/package.json b/packages/queued-request-controller/package.json index dfcb90046d4..d1b526654ad 100644 --- a/packages/queued-request-controller/package.json +++ b/packages/queued-request-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/queued-request-controller", - "version": "9.0.0", + "version": "9.0.1", "description": "Includes a controller and middleware that implements a request queue", "keywords": [ "MetaMask", @@ -47,17 +47,17 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/controller-utils": "^11.4.5", - "@metamask/json-rpc-engine": "^10.0.2", + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", + "@metamask/json-rpc-engine": "^10.0.3", "@metamask/rpc-errors": "^7.0.2", "@metamask/swappable-obj-proxy": "^2.3.0", - "@metamask/utils": "^11.0.1" + "@metamask/utils": "^11.1.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^22.1.1", - "@metamask/selected-network-controller": "^21.0.0", + "@metamask/network-controller": "^22.2.1", + "@metamask/selected-network-controller": "^21.0.1", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "immer": "^9.0.6", diff --git a/packages/queued-request-controller/src/QueuedRequestController.test.ts b/packages/queued-request-controller/src/QueuedRequestController.test.ts index 1a9b51d7e16..4c64a51826a 100644 --- a/packages/queued-request-controller/src/QueuedRequestController.test.ts +++ b/packages/queued-request-controller/src/QueuedRequestController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { getDefaultNetworkControllerState, type NetworkControllerGetStateAction, @@ -35,7 +35,7 @@ describe('QueuedRequestController', () => { }); it('updates queuedRequestCount when flushing requests for an origin', async () => { - const { messenger } = buildControllerMessenger(); + const { messenger } = buildMessenger(); const controller = new QueuedRequestController({ messenger: buildQueuedRequestControllerMessenger(messenger), shouldRequestSwitchNetwork: () => false, @@ -134,7 +134,7 @@ describe('QueuedRequestController', () => { it('switches network if a request comes in for a different network client and shouldRequestSwitchNetwork returns true', async () => { const mockSetActiveNetwork = jest.fn(); - const { messenger } = buildControllerMessenger({ + const { messenger } = buildMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'selectedNetworkClientId', @@ -172,7 +172,7 @@ describe('QueuedRequestController', () => { it('does not switch networks if shouldRequestSwitchNetwork returns false', async () => { const mockSetActiveNetwork = jest.fn(); - const { messenger } = buildControllerMessenger({ + const { messenger } = buildMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'selectedNetworkClientId', @@ -201,7 +201,7 @@ describe('QueuedRequestController', () => { it('does not switch networks if a request comes in for the same network client', async () => { const mockSetActiveNetwork = jest.fn(); - const { messenger } = buildControllerMessenger({ + const { messenger } = buildMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'selectedNetworkClientId', @@ -621,7 +621,7 @@ describe('QueuedRequestController', () => { it('switches network if a new batch has a different network client', async () => { const mockSetActiveNetwork = jest.fn(); - const { messenger } = buildControllerMessenger({ + const { messenger } = buildMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'selectedNetworkClientId', @@ -674,7 +674,7 @@ describe('QueuedRequestController', () => { it('does not switch networks if a new batch has the same network client', async () => { const networkClientId = 'selectedNetworkClientId'; const mockSetActiveNetwork = jest.fn(); - const { messenger } = buildControllerMessenger({ + const { messenger } = buildMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: networkClientId, @@ -752,7 +752,7 @@ describe('QueuedRequestController', () => { it('processes requests from different origins but same networkClientId in separate batches without network switch', async () => { const mockSetActiveNetwork = jest.fn(); - const { messenger } = buildControllerMessenger({ + const { messenger } = buildMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'network1', @@ -796,7 +796,7 @@ describe('QueuedRequestController', () => { it('switches networks between batches with different networkClientIds', async () => { const mockSetActiveNetwork = jest.fn(); - const { messenger } = buildControllerMessenger({ + const { messenger } = buildMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'network1', @@ -851,7 +851,7 @@ describe('QueuedRequestController', () => { return Promise.resolve(); }); - const { messenger } = buildControllerMessenger({ + const { messenger } = buildMessenger({ networkControllerGetState: jest .fn() .mockReturnValueOnce({ @@ -960,7 +960,7 @@ describe('QueuedRequestController', () => { describe('when the network switch for a single request fails', () => { it('throws error', async () => { const switchError = new Error('switch error'); - const { messenger } = buildControllerMessenger({ + const { messenger } = buildMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'selectedNetworkClientId', @@ -990,7 +990,7 @@ describe('QueuedRequestController', () => { it('correctly processes the next item in the queue', async () => { const switchError = new Error('switch error'); - const { messenger } = buildControllerMessenger({ + const { messenger } = buildMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'selectedNetworkClientId', @@ -1037,7 +1037,7 @@ describe('QueuedRequestController', () => { it('throws error', async () => { const switchError = new Error('switch error'); - const { messenger } = buildControllerMessenger({ + const { messenger } = buildMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'mainnet', @@ -1087,7 +1087,7 @@ describe('QueuedRequestController', () => { it('correctly processes the next item in the queue', async () => { const switchError = new Error('switch error'); - const { messenger } = buildControllerMessenger({ + const { messenger } = buildMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'mainnet', @@ -1152,7 +1152,7 @@ describe('QueuedRequestController', () => { describe('when the first request in a batch can switch the network', () => { it('waits on processing the request first in the current batch', async () => { - const { messenger } = buildControllerMessenger({ + const { messenger } = buildMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'mainnet', @@ -1220,7 +1220,7 @@ describe('QueuedRequestController', () => { ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'mainnet', }); - const { messenger } = buildControllerMessenger({ + const { messenger } = buildMessenger({ networkControllerGetState, }); const controller = buildQueuedRequestController({ @@ -1367,7 +1367,7 @@ describe('QueuedRequestController', () => { }); it('rejects requests for an origin when the SelectedNetworkController "domains" state for that origin has changed, but preserves requests for other origins', async () => { - const { messenger } = buildControllerMessenger(); + const { messenger } = buildMessenger(); const options: QueuedRequestControllerOptions = { messenger: buildQueuedRequestControllerMessenger(messenger), @@ -1451,7 +1451,7 @@ describe('QueuedRequestController', () => { }); it('calls clearPendingConfirmations when the SelectedNetworkController "domains" state for that origin has been removed', async () => { - const { messenger } = buildControllerMessenger(); + const { messenger } = buildMessenger(); const options: QueuedRequestControllerOptions = { messenger: buildQueuedRequestControllerMessenger(messenger), @@ -1493,24 +1493,24 @@ describe('QueuedRequestController', () => { }); /** - * Build a controller messenger setup with QueuedRequestController types. + * Build a messenger setup with QueuedRequestController types. * * @param options - Options * @param options.networkControllerGetState - A handler for the `NetworkController:getState` * action. * @param options.networkControllerSetActiveNetwork - A handler for the * `NetworkController:setActiveNetwork` action. - * @returns A controller messenger with QueuedRequestController types, and + * @returns A messenger with QueuedRequestController types, and * mocks for all allowed actions. */ -function buildControllerMessenger({ +function buildMessenger({ networkControllerGetState, networkControllerSetActiveNetwork, }: { networkControllerGetState?: NetworkControllerGetStateAction['handler']; networkControllerSetActiveNetwork?: NetworkControllerSetActiveNetworkAction['handler']; } = {}): { - messenger: ControllerMessenger< + messenger: Messenger< QueuedRequestControllerActions | AllowedActions, QueuedRequestControllerEvents | AllowedEvents >; @@ -1521,7 +1521,7 @@ function buildControllerMessenger({ NetworkControllerSetActiveNetworkAction['handler'] >; } { - const messenger = new ControllerMessenger< + const messenger = new Messenger< QueuedRequestControllerActions | AllowedActions, QueuedRequestControllerEvents | AllowedEvents >(); @@ -1551,13 +1551,13 @@ function buildControllerMessenger({ } /** - * Builds a restricted controller messenger for the queued request controller. + * Builds a restricted messenger for the queued request controller. * - * @param messenger - A controller messenger. - * @returns The restricted controller messenger. + * @param messenger - A messenger. + * @returns The restricted messenger. */ function buildQueuedRequestControllerMessenger( - messenger = buildControllerMessenger().messenger, + messenger = buildMessenger().messenger, ): QueuedRequestControllerMessenger { return messenger.getRestricted({ name: controllerName, diff --git a/packages/queued-request-controller/src/QueuedRequestController.ts b/packages/queued-request-controller/src/QueuedRequestController.ts index 88958cae6e9..5b8702cfc65 100644 --- a/packages/queued-request-controller/src/QueuedRequestController.ts +++ b/packages/queued-request-controller/src/QueuedRequestController.ts @@ -1,7 +1,7 @@ import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { @@ -66,7 +66,7 @@ export type AllowedActions = export type AllowedEvents = SelectedNetworkControllerStateChangeEvent; -export type QueuedRequestControllerMessenger = RestrictedControllerMessenger< +export type QueuedRequestControllerMessenger = RestrictedMessenger< typeof controllerName, QueuedRequestControllerActions | AllowedActions, QueuedRequestControllerEvents | AllowedEvents, @@ -190,7 +190,7 @@ export class QueuedRequestController extends BaseController< * Construct a QueuedRequestController. * * @param options - Controller options. - * @param options.messenger - The restricted controller messenger that facilitates communication with other controllers. + * @param options.messenger - The restricted messenger that facilitates communication with other controllers. * @param options.shouldRequestSwitchNetwork - A function that returns if a request requires the globally selected network to match the dapp selected network. * @param options.canRequestSwitchNetworkWithoutApproval - A function that returns if a request will switch the globally selected network without prompting for user approval. * @param options.clearPendingConfirmations - A function that will clear all the pending confirmations. diff --git a/packages/rate-limit-controller/CHANGELOG.md b/packages/rate-limit-controller/CHANGELOG.md index 0488a53c7b4..e7ffbf34e54 100644 --- a/packages/rate-limit-controller/CHANGELOG.md +++ b/packages/rate-limit-controller/CHANGELOG.md @@ -7,9 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [6.0.3] + ### Changed -- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) +- Bump `@metamask/base-controller` from `^7.0.2` to `^8.0.0` ([#5079](https://github.com/MetaMask/core/pull/5079)), ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/rpc-errors` from `^7.0.1` to `^7.0.2` ([#5080](https://github.com/MetaMask/core/pull/5080)) +- Bump `@metamask/utils` from `^10.0.0` to `^11.1.0` ([#5080](https://github.com/MetaMask/core/pull/5080)), ([#5223](https://github.com/MetaMask/core/pull/5223)) ## [6.0.2] @@ -173,7 +177,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/rate-limit-controller@6.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/rate-limit-controller@6.0.3...HEAD +[6.0.3]: https://github.com/MetaMask/core/compare/@metamask/rate-limit-controller@6.0.2...@metamask/rate-limit-controller@6.0.3 [6.0.2]: https://github.com/MetaMask/core/compare/@metamask/rate-limit-controller@6.0.1...@metamask/rate-limit-controller@6.0.2 [6.0.1]: https://github.com/MetaMask/core/compare/@metamask/rate-limit-controller@6.0.0...@metamask/rate-limit-controller@6.0.1 [6.0.0]: https://github.com/MetaMask/core/compare/@metamask/rate-limit-controller@5.0.2...@metamask/rate-limit-controller@6.0.0 diff --git a/packages/rate-limit-controller/package.json b/packages/rate-limit-controller/package.json index 660c4e5d796..18c5512a8d7 100644 --- a/packages/rate-limit-controller/package.json +++ b/packages/rate-limit-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/rate-limit-controller", - "version": "6.0.2", + "version": "6.0.3", "description": "Contains logic for rate-limiting API endpoints by requesting origin", "keywords": [ "MetaMask", @@ -47,9 +47,9 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", + "@metamask/base-controller": "^8.0.0", "@metamask/rpc-errors": "^7.0.2", - "@metamask/utils": "^11.0.1" + "@metamask/utils": "^11.1.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", diff --git a/packages/rate-limit-controller/src/RateLimitController.test.ts b/packages/rate-limit-controller/src/RateLimitController.test.ts index 606d7dd18f6..a193502d0c9 100644 --- a/packages/rate-limit-controller/src/RateLimitController.test.ts +++ b/packages/rate-limit-controller/src/RateLimitController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import type { RateLimitControllerActions, @@ -22,27 +22,25 @@ const implementations = { type RateLimitedApis = typeof implementations; /** - * Constructs a unrestricted controller messenger. + * Constructs an unrestricted messenger. * - * @returns A unrestricted controller messenger. + * @returns An unrestricted messenger. */ function getUnrestrictedMessenger() { - return new ControllerMessenger< + return new Messenger< RateLimitControllerActions, RateLimitControllerEvents >(); } /** - * Constructs a restricted controller messenger. + * Constructs a restricted messenger. * - * @param controllerMessenger - An optional unrestricted messenger - * @returns A restricted controller messenger. + * @param messenger - An optional unrestricted messenger + * @returns A restricted messenger. */ -function getRestrictedMessenger( - controllerMessenger = getUnrestrictedMessenger(), -) { - return controllerMessenger.getRestricted({ +function getRestrictedMessenger(messenger = getUnrestrictedMessenger()) { + return messenger.getRestricted({ name, allowedActions: [], allowedEvents: [], diff --git a/packages/rate-limit-controller/src/RateLimitController.ts b/packages/rate-limit-controller/src/RateLimitController.ts index 7ac65c5b55f..10665bd3fbb 100644 --- a/packages/rate-limit-controller/src/RateLimitController.ts +++ b/packages/rate-limit-controller/src/RateLimitController.ts @@ -1,6 +1,6 @@ import type { ActionConstraint, - RestrictedControllerMessenger, + RestrictedMessenger, ControllerGetStateAction, ControllerStateChangeEvent, } from '@metamask/base-controller'; @@ -69,7 +69,7 @@ export type RateLimitControllerEvents< > = RateLimitControllerStateChangeEvent; export type RateLimitMessenger = - RestrictedControllerMessenger< + RestrictedMessenger< typeof name, RateLimitControllerActions, RateLimitControllerEvents, diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 20df3a0a4ca..3b12df1266c 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] +## [1.4.0] + +### Added + +- Add `onBreak` and `onDegraded` methods to `ClientConfigApiService` ([#5109](https://github.com/MetaMask/core/pull/5109)) + - These serve the same purpose as the `onBreak` and `onDegraded` constructor options, but align more closely with the Cockatiel policy API. + +### Changed + +- Deprecate `ClientConfigApiService` constructor options `onBreak` and `onDegraded` in favor of methods ([#5109](https://github.com/MetaMask/core/pull/5109)) +- Add `@metamask/controller-utils@^11.5.0` as a dependency ([#5109](https://github.com/MetaMask/core/pull/5109)), ([#5272](https://github.com/MetaMask/core/pull/5272)) + - `cockatiel` should still be in the dependency tree because it's now a dependency of `@metamask/controller-utils` +- Bump `@metamask/base-controller` from `^7.1.0` to `^8.0.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + ## [1.3.0] ### Changed @@ -42,7 +57,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@1.3.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/remote-feature-flag-controller@1.4.0...HEAD +[1.4.0]: https://github.com/MetaMask/core/compare/@metamask/remote-feature-flag-controller@1.3.0...@metamask/remote-feature-flag-controller@1.4.0 [1.3.0]: https://github.com/MetaMask/core/compare/@metamask/remote-feature-flag-controller@1.2.0...@metamask/remote-feature-flag-controller@1.3.0 [1.2.0]: https://github.com/MetaMask/core/compare/@metamask/remote-feature-flag-controller@1.1.0...@metamask/remote-feature-flag-controller@1.2.0 [1.1.0]: https://github.com/MetaMask/core/compare/@metamask/remote-feature-flag-controller@1.0.0...@metamask/remote-feature-flag-controller@1.1.0 diff --git a/packages/remote-feature-flag-controller/package.json b/packages/remote-feature-flag-controller/package.json index 38128e1b2df..795df6d2d40 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": "1.3.0", + "version": "1.4.0", "description": "The RemoteFeatureFlagController manages the retrieval and caching of remote feature flags", "keywords": [ "MetaMask", @@ -47,15 +47,14 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/utils": "^11.0.1", - "cockatiel": "^3.1.2", + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", + "@metamask/utils": "^11.1.0", "uuid": "^8.3.2" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", "@metamask/auto-changelog": "^3.4.4", - "@metamask/controller-utils": "^11.4.5", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/remote-feature-flag-controller/src/client-config-api-service/abstract-client-config-api-service.ts b/packages/remote-feature-flag-controller/src/client-config-api-service/abstract-client-config-api-service.ts index a7def3bef56..7a07ed2db9b 100644 --- a/packages/remote-feature-flag-controller/src/client-config-api-service/abstract-client-config-api-service.ts +++ b/packages/remote-feature-flag-controller/src/client-config-api-service/abstract-client-config-api-service.ts @@ -1,9 +1,20 @@ -import type { PublicInterface } from '@metamask/utils'; +import type { ServicePolicy } from '@metamask/controller-utils'; -import type { ClientConfigApiService } from './client-config-api-service'; +import type { ServiceResponse } from '../remote-feature-flag-controller-types'; /** * A service object responsible for fetching feature flags. */ -export type AbstractClientConfigApiService = - PublicInterface; +export type AbstractClientConfigApiService = Partial< + Pick +> & { + /** + * Fetches feature flags from the API with specific client, distribution, and + * environment parameters. Provides structured error handling, including + * fallback to cached data if available. + * + * @returns An object of feature flags and their boolean values or a + * structured error object. + */ + fetchRemoteFeatureFlags(): Promise; +}; diff --git a/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts index 1baaaf72bf4..0cd8756d47c 100644 --- a/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts +++ b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts @@ -20,9 +20,68 @@ const mockFeatureFlags: FeatureFlags = { feature2: { chrome: '<109' }, }; +jest.setTimeout(8000); + describe('ClientConfigApiService', () => { const networkError = new Error('Network error'); + describe('onBreak', () => { + it('should register a listener that is called when the circuit opens', async () => { + const onBreak = jest.fn(); + const mockFetch = createMockFetch({ error: networkError }); + + const clientConfigApiService = new ClientConfigApiService({ + fetch: mockFetch, + maximumConsecutiveFailures: 1, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }); + clientConfigApiService.onBreak(onBreak); + + await expect( + clientConfigApiService.fetchRemoteFeatureFlags(), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + + expect(onBreak).toHaveBeenCalled(); + }); + }); + + describe('onDegraded', () => { + it('should register a listener that is called when the request is slow', async () => { + const onDegraded = jest.fn(); + const slowFetchTime = 5500; // Exceed the DEFAULT_DEGRADED_THRESHOLD (5000ms) + // Mock fetch to take a long time + const mockSlowFetch = createMockFetch({ + response: { + ok: true, + status: 200, + json: async () => mockServerFeatureFlagsResponse, + }, + delay: slowFetchTime, + }); + + const clientConfigApiService = new ClientConfigApiService({ + fetch: mockSlowFetch, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }); + clientConfigApiService.onDegraded(onDegraded); + + await clientConfigApiService.fetchRemoteFeatureFlags(); + + // Verify the degraded callback was called + expect(onDegraded).toHaveBeenCalled(); + }, 7000); + }); + describe('fetchRemoteFeatureFlags', () => { it('fetches successfully and returns feature flags', async () => { const mockFetch = createMockFetch({ @@ -132,43 +191,8 @@ describe('ClientConfigApiService', () => { // Check that fetch was retried the correct number of times expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1); // Initial + retries }); - }); - - describe('circuit breaker', () => { - it('opens the circuit breaker after consecutive failures', async () => { - const mockFetch = createMockFetch({ error: networkError }); - const maxFailures = 3; - const clientConfigApiService = new ClientConfigApiService({ - fetch: mockFetch, - maximumConsecutiveFailures: maxFailures, - config: { - client: ClientType.Extension, - distribution: DistributionType.Main, - environment: EnvironmentType.Production, - }, - }); - - // Attempt requests until circuit breaker opens - for (let i = 0; i < maxFailures; i++) { - await expect( - clientConfigApiService.fetchRemoteFeatureFlags(), - ).rejects.toThrow( - /Network error|Execution prevented because the circuit breaker is open/u, - ); - } - - // Verify the circuit breaker is now open - await expect( - clientConfigApiService.fetchRemoteFeatureFlags(), - ).rejects.toThrow( - 'Execution prevented because the circuit breaker is open', - ); - - // Verify fetch was called the expected number of times - expect(mockFetch).toHaveBeenCalledTimes(maxFailures); - }); - it('should call onBreak when circuit breaker opens', async () => { + it('should call the onBreak callback when the circuit opens', async () => { const onBreak = jest.fn(); const mockFetch = createMockFetch({ error: networkError }); @@ -192,8 +216,7 @@ describe('ClientConfigApiService', () => { expect(onBreak).toHaveBeenCalled(); }); - it('should call the onDegraded callback when requests are slow', async () => { - jest.setTimeout(7000); + it('should call the onDegraded callback when the request is slow', async () => { const onDegraded = jest.fn(); const slowFetchTime = 5500; // Exceed the DEFAULT_DEGRADED_THRESHOLD (5000ms) // Mock fetch to take a long time @@ -221,64 +244,6 @@ describe('ClientConfigApiService', () => { // Verify the degraded callback was called expect(onDegraded).toHaveBeenCalled(); }, 7000); - - it('should succeed on a subsequent fetch attempt after retries', async () => { - const maxRetries = 2; - // Mock fetch to fail initially, then succeed - const mockFetch = jest - .fn() - .mockRejectedValueOnce(networkError) - .mockRejectedValueOnce(networkError) - .mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => mockServerFeatureFlagsResponse, - }); - - const clientConfigApiService = new ClientConfigApiService({ - fetch: mockFetch, - retries: maxRetries, - config: { - client: ClientType.Extension, - distribution: DistributionType.Main, - environment: EnvironmentType.Production, - }, - }); - - const result = await clientConfigApiService.fetchRemoteFeatureFlags(); - - expect(result).toStrictEqual({ - remoteFeatureFlags: mockFeatureFlags, - cacheTimestamp: expect.any(Number), - }); - - expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1); - }); - - it('calls onDegraded when retries are exhausted and circuit is closed', async () => { - const onDegraded = jest.fn(); - const mockFetch = jest.fn(); - mockFetch.mockRejectedValue(new Error('Network error')); - - const service = new ClientConfigApiService({ - fetch: mockFetch, - retries: 2, - onDegraded, - config: { - client: ClientType.Extension, - distribution: DistributionType.Main, - environment: EnvironmentType.Production, - }, - }); - - await expect(service.fetchRemoteFeatureFlags()).rejects.toThrow( - 'Network error', - ); - - // Should be called once after all retries are exhausted - expect(onDegraded).toHaveBeenCalledTimes(1); - }); }); }); diff --git a/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts index 7cd9177e0b9..73e642526d8 100644 --- a/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts +++ b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts @@ -1,14 +1,12 @@ import { - circuitBreaker, - ConsecutiveBreaker, - ExponentialBackoff, - handleAll, - type IPolicy, - retry, - wrap, - CircuitState, -} from 'cockatiel'; - + createServicePolicy, + DEFAULT_CIRCUIT_BREAK_DURATION, + DEFAULT_MAX_CONSECUTIVE_FAILURES, + DEFAULT_MAX_RETRIES, +} from '@metamask/controller-utils'; +import type { ServicePolicy } from '@metamask/controller-utils'; + +import type { AbstractClientConfigApiService } from './abstract-client-config-api-service'; import { BASE_URL } from '../constants'; import type { FeatureFlags, @@ -19,19 +17,13 @@ import type { ApiDataResponse, } from '../remote-feature-flag-controller-types'; -const DEFAULT_FETCH_RETRIES = 3; -// Each update attempt will result (1 + retries) calls if the server is down -const DEFAULT_MAX_CONSECUTIVE_FAILURES = (1 + DEFAULT_FETCH_RETRIES) * 3; - -export const DEFAULT_DEGRADED_THRESHOLD = 5000; - /** * This service is responsible for fetching feature flags from the ClientConfig API. */ -export class ClientConfigApiService { +export class ClientConfigApiService implements AbstractClientConfigApiService { #fetch: typeof fetch; - #policy: IPolicy; + readonly #policy: ServicePolicy; #client: ClientType; @@ -44,23 +36,83 @@ export class ClientConfigApiService { * * @param args - The arguments. * @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.retries - Number of retry attempts for each fetch request. - * @param args.maximumConsecutiveFailures - The maximum number of consecutive failures - * allowed before breaking the circuit and pausing further fetch attempts. - * @param args.circuitBreakDuration - The duration for which the circuit remains open after - * too many consecutive failures. - * @param args.onBreak - Callback invoked when the circuit breaks. - * @param args.onDegraded - Callback invoked when the service is degraded (requests resolving too slowly). - * @param args.config - The configuration object, includes client, distribution, and environment. + * @param args.maximumConsecutiveFailures - The maximum number of consecutive + * failures allowed before breaking the circuit and pausing further fetch + * attempts. + * @param args.circuitBreakDuration - The amount of time to wait when the + * circuit breaks from too many consecutive failures. + * @param args.config - The configuration object, includes client, + * distribution, and environment. * @param args.config.client - The client type (e.g., 'extension', 'mobile'). - * @param args.config.distribution - The distribution type (e.g., 'main', 'flask'). - * @param args.config.environment - The environment type (e.g., 'prod', 'rc', 'dev'). + * @param args.config.distribution - The distribution type (e.g., 'main', + * 'flask'). + * @param args.config.environment - The environment type (e.g., 'prod', 'rc', + * 'dev'). */ + constructor(args: { + fetch: typeof fetch; + retries?: number; + maximumConsecutiveFailures?: number; + circuitBreakDuration?: number; + config: { + client: ClientType; + distribution: DistributionType; + environment: EnvironmentType; + }; + }); + + /** + * Constructs a new ClientConfigApiService object. + * + * @deprecated This signature is deprecated; please use the `onBreak` and + * `onDegraded` methods instead. + * @param args - The arguments. + * @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.retries - Number of retry attempts for each fetch request. + * @param args.maximumConsecutiveFailures - The maximum number of consecutive + * failures allowed before breaking the circuit and pausing further fetch + * attempts. + * @param args.circuitBreakDuration - The amount of time to wait when the + * circuit breaks from too many consecutive failures. + * @param args.onBreak - Callback for when the circuit breaks, useful + * for capturing metrics about network failures. + * @param args.onDegraded - Callback for when the API responds successfully + * but takes too long to respond (5 seconds or more). + * @param args.config - The configuration object, includes client, + * distribution, and environment. + * @param args.config.client - The client type (e.g., 'extension', 'mobile'). + * @param args.config.distribution - The distribution type (e.g., 'main', + * 'flask'). + * @param args.config.environment - The environment type (e.g., 'prod', 'rc', + * 'dev'). + */ + // eslint-disable-next-line @typescript-eslint/unified-signatures + constructor(args: { + fetch: typeof fetch; + retries?: number; + maximumConsecutiveFailures?: number; + circuitBreakDuration?: number; + onBreak?: () => void; + onDegraded?: () => void; + config: { + client: ClientType; + distribution: DistributionType; + environment: EnvironmentType; + }; + }); + constructor({ fetch: fetchFunction, - retries = DEFAULT_FETCH_RETRIES, + retries = DEFAULT_MAX_RETRIES, maximumConsecutiveFailures = DEFAULT_MAX_CONSECUTIVE_FAILURES, - circuitBreakDuration = 30 * 60 * 1000, + circuitBreakDuration = DEFAULT_CIRCUIT_BREAK_DURATION, onBreak, onDegraded, config, @@ -82,38 +134,39 @@ export class ClientConfigApiService { this.#distribution = config.distribution; this.#environment = config.environment; - const retryPolicy = retry(handleAll, { - maxAttempts: retries, - backoff: new ExponentialBackoff(), - }); - - const circuitBreakerPolicy = circuitBreaker(handleAll, { - halfOpenAfter: circuitBreakDuration, - breaker: new ConsecutiveBreaker(maximumConsecutiveFailures), + this.#policy = createServicePolicy({ + maxRetries: retries, + maxConsecutiveFailures: maximumConsecutiveFailures, + circuitBreakDuration, }); - if (onBreak) { - circuitBreakerPolicy.onBreak(onBreak); + this.#policy.onBreak(onBreak); } - if (onDegraded) { - retryPolicy.onGiveUp(() => { - if (circuitBreakerPolicy.state === CircuitState.Closed) { - onDegraded(); - } - }); - - retryPolicy.onSuccess(({ duration }) => { - if ( - circuitBreakerPolicy.state === CircuitState.Closed && - duration > DEFAULT_DEGRADED_THRESHOLD // Default degraded threshold - ) { - onDegraded(); - } - }); + this.#policy.onDegraded(onDegraded); } + } - this.#policy = wrap(retryPolicy, circuitBreakerPolicy); + /** + * Listens for when the request to the API fails too many times in a row. + * + * @param args - The same arguments that {@link ServicePolicy.onBreak} + * takes. + * @returns What {@link ServicePolicy.onBreak} returns. + */ + onBreak(...args: Parameters) { + return this.#policy.onBreak(...args); + } + + /** + * Listens for when the API is degraded. + * + * @param args - The same arguments that {@link ServicePolicy.onDegraded} + * takes. + * @returns What {@link ServicePolicy.onDegraded} returns. + */ + onDegraded(...args: Parameters) { + return this.#policy.onDegraded(...args); } /** 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 5def1e901a9..f440b2d1081 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,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import type { AbstractClientConfigApiService } from './client-config-api-service/abstract-client-config-api-service'; import { @@ -44,8 +44,9 @@ const MOCK_METRICS_ID = 'f9e8d7c6-b5a4-4210-9876-543210fedcba'; /** * Creates a controller instance with default parameters for testing + * * @param options - The controller configuration options - * @param options.messenger - The controller messenger instance + * @param options.messenger - The messenger instance * @param options.state - The initial controller state * @param options.clientConfigApiService - The client config API service instance * @param options.disabled - Whether the controller should start disabled @@ -61,7 +62,7 @@ function createController( }> = {}, ) { return new RemoteFeatureFlagController({ - messenger: getControllerMessenger(), + messenger: getMessenger(), state: options.state, clientConfigApiService: options.clientConfigApiService ?? buildClientConfigApiService(), @@ -346,23 +347,22 @@ type RootAction = RemoteFeatureFlagControllerActions; type RootEvent = RemoteFeatureFlagControllerStateChangeEvent; /** - * Creates and returns a root controller messenger for testing - * @returns A controller messenger instance + * Creates and returns a root messenger for testing + * + * @returns A messenger instance */ -function getRootControllerMessenger(): ControllerMessenger< - RootAction, - RootEvent -> { - return new ControllerMessenger(); +function getRootMessenger(): Messenger { + return new Messenger(); } /** - * Creates a restricted controller messenger for testing + * Creates a restricted messenger for testing + * * @param rootMessenger - The root messenger to restrict - * @returns A restricted controller messenger instance + * @returns A restricted messenger instance */ -function getControllerMessenger( - rootMessenger = getRootControllerMessenger(), +function getMessenger( + rootMessenger = getRootMessenger(), ): RemoteFeatureFlagControllerMessenger { return rootMessenger.getRestricted({ name: controllerName, @@ -373,6 +373,7 @@ function getControllerMessenger( /** * Builds a mock client config API service for testing + * * @param options - The options object * @param options.remoteFeatureFlags - Optional feature flags data to return * @param options.cacheTimestamp - Optional timestamp to use for the cache 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 9e370998baa..dc1f60c99f1 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,7 +1,7 @@ import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; @@ -62,14 +62,13 @@ export type RemoteFeatureFlagControllerEvents = export type AllowedEvents = never; -export type RemoteFeatureFlagControllerMessenger = - RestrictedControllerMessenger< - typeof controllerName, - RemoteFeatureFlagControllerActions | AllowedActions, - RemoteFeatureFlagControllerEvents | AllowedEvents, - AllowedActions['type'], - AllowedEvents['type'] - >; +export type RemoteFeatureFlagControllerMessenger = RestrictedMessenger< + typeof controllerName, + RemoteFeatureFlagControllerActions | AllowedActions, + RemoteFeatureFlagControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; /** * Returns the default state for the RemoteFeatureFlagController. @@ -98,7 +97,7 @@ export class RemoteFeatureFlagController extends BaseController< #disabled: boolean; - #clientConfigApiService: AbstractClientConfigApiService; + readonly #clientConfigApiService: AbstractClientConfigApiService; #inProgressFlagUpdate?: Promise; @@ -108,7 +107,7 @@ export class RemoteFeatureFlagController extends BaseController< * Constructs a new RemoteFeatureFlagController instance. * * @param options - The controller options. - * @param options.messenger - The controller messenger used for communication. + * @param options.messenger - The messenger used for communication. * @param options.state - The initial state of the controller. * @param options.clientConfigApiService - The service instance to fetch remote feature flags. * @param options.fetchInterval - The interval in milliseconds before cached flags expire. Defaults to 1 day. @@ -193,9 +192,7 @@ export class RemoteFeatureFlagController extends BaseController< * @private */ async #updateCache(remoteFeatureFlags: FeatureFlags) { - const processedRemoteFeatureFlags = await this.#processRemoteFeatureFlags( - remoteFeatureFlags, - ); + const processedRemoteFeatureFlags = await this.#processRemoteFeatureFlags(remoteFeatureFlags); this.update(() => { return { remoteFeatureFlags: processedRemoteFeatureFlags, diff --git a/packages/selected-network-controller/CHANGELOG.md b/packages/selected-network-controller/CHANGELOG.md index a3c99091484..950cc7b7e03 100644 --- a/packages/selected-network-controller/CHANGELOG.md +++ b/packages/selected-network-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [21.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/json-rpc-engine` from `^10.0.2` to `^10.0.3` ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + ## [21.0.0] ### Added @@ -337,7 +345,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial Release ([#1643](https://github.com/MetaMask/core/pull/1643)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@21.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@21.0.1...HEAD +[21.0.1]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@21.0.0...@metamask/selected-network-controller@21.0.1 [21.0.0]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@20.0.2...@metamask/selected-network-controller@21.0.0 [20.0.2]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@20.0.1...@metamask/selected-network-controller@20.0.2 [20.0.1]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@20.0.0...@metamask/selected-network-controller@20.0.1 diff --git a/packages/selected-network-controller/package.json b/packages/selected-network-controller/package.json index 84175799781..f86208c1cbc 100644 --- a/packages/selected-network-controller/package.json +++ b/packages/selected-network-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/selected-network-controller", - "version": "21.0.0", + "version": "21.0.1", "description": "Provides an interface to the currently selected networkClientId for a given domain", "keywords": [ "MetaMask", @@ -47,15 +47,15 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/json-rpc-engine": "^10.0.2", + "@metamask/base-controller": "^8.0.0", + "@metamask/json-rpc-engine": "^10.0.3", "@metamask/swappable-obj-proxy": "^2.3.0", - "@metamask/utils": "^11.0.1" + "@metamask/utils": "^11.1.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^22.1.1", - "@metamask/permission-controller": "^11.0.5", + "@metamask/network-controller": "^22.2.1", + "@metamask/permission-controller": "^11.0.6", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "immer": "^9.0.6", diff --git a/packages/selected-network-controller/src/SelectedNetworkController.ts b/packages/selected-network-controller/src/SelectedNetworkController.ts index 8f73418122a..a70a55ab2bd 100644 --- a/packages/selected-network-controller/src/SelectedNetworkController.ts +++ b/packages/selected-network-controller/src/SelectedNetworkController.ts @@ -1,4 +1,4 @@ -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; +import type { RestrictedMessenger } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { BlockTrackerProxy, @@ -91,7 +91,7 @@ export type AllowedEvents = | NetworkControllerStateChangeEvent | PermissionControllerStateChange; -export type SelectedNetworkControllerMessenger = RestrictedControllerMessenger< +export type SelectedNetworkControllerMessenger = RestrictedMessenger< typeof controllerName, SelectedNetworkControllerActions | AllowedActions, SelectedNetworkControllerEvents | AllowedEvents, @@ -130,7 +130,7 @@ export class SelectedNetworkController extends BaseController< * Construct a SelectedNetworkController controller. * * @param options - The controller options. - * @param options.messenger - The restricted controller messenger for the EncryptionPublicKey controller. + * @param options.messenger - The restricted messenger for the EncryptionPublicKey controller. * @param options.state - The controllers initial state. * @param options.useRequestQueuePreference - A boolean indicating whether to use the request queue preference. * @param options.onPreferencesStateChange - A callback that is called when the preference state changes. diff --git a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts index 847f1b5898e..36c07354d26 100644 --- a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts +++ b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { type ProviderProxy, type BlockTrackerProxy, @@ -25,33 +25,33 @@ import { } from '../src/SelectedNetworkController'; /** - * Builds a new instance of the ControllerMessenger class for the SelectedNetworkController. + * Builds a new instance of the Messenger class for the SelectedNetworkController. * - * @returns A new instance of the ControllerMessenger class for the SelectedNetworkController. + * @returns A new instance of the Messenger class for the SelectedNetworkController. */ function buildMessenger() { - return new ControllerMessenger< + return new Messenger< SelectedNetworkControllerActions | AllowedActions, SelectedNetworkControllerEvents | AllowedEvents >(); } /** - * Build a restricted controller messenger for the selected network controller. + * Build a restricted messenger for the selected network controller. * * @param options - The options bag. - * @param options.messenger - A controller messenger. + * @param options.messenger - A messenger. * @param options.getSubjectNames - Permissions controller list of domains with permissions * @returns The network controller restricted messenger. */ function buildSelectedNetworkControllerMessenger({ - messenger = new ControllerMessenger< + messenger = new Messenger< SelectedNetworkControllerActions | AllowedActions, SelectedNetworkControllerEvents | AllowedEvents >(), getSubjectNames, }: { - messenger?: ControllerMessenger< + messenger?: Messenger< SelectedNetworkControllerActions | AllowedActions, SelectedNetworkControllerEvents | AllowedEvents >; diff --git a/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts b/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts index b3344d260b4..13d66bcacd7 100644 --- a/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts +++ b/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import type { JsonRpcResponse } from '@metamask/utils'; @@ -13,7 +13,7 @@ import type { SelectedNetworkMiddlewareJsonRpcRequest } from '../src/SelectedNet import { createSelectedNetworkMiddleware } from '../src/SelectedNetworkMiddleware'; const buildMessenger = () => { - return new ControllerMessenger< + return new Messenger< SelectedNetworkControllerActions | AllowedActions, SelectedNetworkControllerEvents | AllowedEvents >(); diff --git a/packages/signature-controller/CHANGELOG.md b/packages/signature-controller/CHANGELOG.md index b44e726d681..0cb234bcf02 100644 --- a/packages/signature-controller/CHANGELOG.md +++ b/packages/signature-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [23.2.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.0` to `^8.0.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + ## [23.2.0] ### Changed @@ -453,7 +461,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@23.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@23.2.1...HEAD +[23.2.1]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@23.2.0...@metamask/signature-controller@23.2.1 [23.2.0]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@23.1.0...@metamask/signature-controller@23.2.0 [23.1.0]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@23.0.1...@metamask/signature-controller@23.1.0 [23.0.1]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@23.0.0...@metamask/signature-controller@23.0.1 diff --git a/packages/signature-controller/package.json b/packages/signature-controller/package.json index 466b326851a..5735de1ad9f 100644 --- a/packages/signature-controller/package.json +++ b/packages/signature-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/signature-controller", - "version": "23.2.0", + "version": "23.2.1", "description": "Processes signing requests in order to sign arbitrary and typed data", "keywords": [ "MetaMask", @@ -47,20 +47,20 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/controller-utils": "^11.4.5", + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", "@metamask/eth-sig-util": "^8.0.0", - "@metamask/utils": "^11.0.1", + "@metamask/utils": "^11.1.0", "jsonschema": "^1.4.1", "lodash": "^4.17.21", "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/approval-controller": "^7.1.2", + "@metamask/approval-controller": "^7.1.3", "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^19.0.4", - "@metamask/logging-controller": "^6.0.3", - "@metamask/network-controller": "^22.1.1", + "@metamask/keyring-controller": "^19.1.0", + "@metamask/logging-controller": "^6.0.4", + "@metamask/network-controller": "^22.2.1", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/signature-controller/src/SignatureController.ts b/packages/signature-controller/src/SignatureController.ts index 239cd76386c..af4347b6f31 100644 --- a/packages/signature-controller/src/SignatureController.ts +++ b/packages/signature-controller/src/SignatureController.ts @@ -6,7 +6,7 @@ import type { import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { TraceCallback, TraceContext } from '@metamask/controller-utils'; @@ -134,7 +134,7 @@ export type SignatureControllerActions = GetSignatureState; export type SignatureControllerEvents = SignatureStateChange; -export type SignatureControllerMessenger = RestrictedControllerMessenger< +export type SignatureControllerMessenger = RestrictedMessenger< typeof controllerName, SignatureControllerActions | AllowedActions, SignatureControllerEvents, @@ -144,7 +144,7 @@ export type SignatureControllerMessenger = RestrictedControllerMessenger< export type SignatureControllerOptions = { /** - * Restricted controller messenger required by the signature controller. + * Restricted messenger required by the signature controller. */ messenger: SignatureControllerMessenger; @@ -201,7 +201,7 @@ export class SignatureController extends BaseController< * @param options - The controller options. * @param options.decodingApiUrl - Api used to get decoded data for permits. * @param options.isDecodeSignatureRequestEnabled - Function to check is decoding signature request is enabled. - * @param options.messenger - The restricted controller messenger for the sign controller. + * @param options.messenger - The restricted messenger for the sign controller. * @param options.state - Initial state to set on this controller. * @param options.trace - Callback to generate trace information. */ diff --git a/packages/token-search-discovery-controller/CHANGELOG.md b/packages/token-search-discovery-controller/CHANGELOG.md index fe2906f5e8d..0176ad20743 100644 --- a/packages/token-search-discovery-controller/CHANGELOG.md +++ b/packages/token-search-discovery-controller/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.1.0] + +### Added + +- Export `TokenSearchDiscoveryControllerMessenger` type ([#5296](https://github.com/MetaMask/core/pull/5296)) + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) + +## [2.0.0] + +### Added + +- Introduce the `logoUrl` property to the `TokenSearchApiService` response ([#5195](https://github.com/MetaMask/core/pull/5195)) + - Specifically in the `TokenSearchResponseItem` type +- Introduce `TokenDiscoveryApiService` to keep discovery and search responsibilities separate ([#5214](https://github.com/MetaMask/core/pull/5214)) + - This service is responsible for fetching discover related data + - Add `getTrendingTokens` method to fetch trending tokens by chain + - Add `TokenTrendingResponseItem` type for trending token responses +- Export `TokenSearchResponseItem` type from the package index ([#5214](https://github.com/MetaMask/core/pull/5214)) + +### Changed + +- Bump @metamask/utils to v11.1.0 ([#5223](https://github.com/MetaMask/core/pull/5223)) +- Update the `TokenSearchApiService` to use the updated URL for `searchTokens` ([#5195](https://github.com/MetaMask/core/pull/5195)) + - The URL is now `/tokens-search` instead of `/tokens-search/name` +- **BREAKING:** The `searchTokens` method now takes a `query` parameter instead of `name` ([#5195](https://github.com/MetaMask/core/pull/5195)) + ## [1.0.0] ### Added @@ -14,8 +43,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Introduce the TokenSearchDiscoveryController ([#5142](https://github.com/MetaMask/core/pull/5142/)) - This controller manages token search and discovery through the Portfolio API - Introduce the TokenSearchApiService ([#5142](https://github.com/MetaMask/core/pull/5142/)) - - This service is responsible for making requests to the Portfolio API + - This service is responsible for making search related requests to the Portfolio API - Specifically, it handles the `tokens-search` endpoint which returns a list of tokens based on the provided query parameters -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/token-search-discovery-controller@1.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/token-search-discovery-controller@2.1.0...HEAD +[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/token-search-discovery-controller@2.0.0...@metamask/token-search-discovery-controller@2.1.0 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/token-search-discovery-controller@1.0.0...@metamask/token-search-discovery-controller@2.0.0 [1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/token-search-discovery-controller@1.0.0 diff --git a/packages/token-search-discovery-controller/package.json b/packages/token-search-discovery-controller/package.json index 80233d33c76..620c031c16b 100644 --- a/packages/token-search-discovery-controller/package.json +++ b/packages/token-search-discovery-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/token-search-discovery-controller", - "version": "1.0.0", + "version": "2.1.0", "description": "Manages token search and discovery through the Portfolio API", "keywords": [ "MetaMask", @@ -47,14 +47,15 @@ "since-latest-release": "../../scripts/since-latest-release.sh" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/utils": "^11.0.1" + "@metamask/base-controller": "^8.0.0", + "@metamask/utils": "^11.1.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", + "nock": "^13.3.1", "ts-jest": "^27.1.4", "typedoc": "^0.24.8", "typedoc-plugin-missing-exports": "^2.0.0", diff --git a/packages/token-search-discovery-controller/src/index.ts b/packages/token-search-discovery-controller/src/index.ts index ce5c5022ef1..1ac1c2f0287 100644 --- a/packages/token-search-discovery-controller/src/index.ts +++ b/packages/token-search-discovery-controller/src/index.ts @@ -1,6 +1,17 @@ export { TokenSearchDiscoveryController } from './token-search-discovery-controller'; -export type { TokenSearchDiscoveryControllerState } from './token-search-discovery-controller'; -export type { TokenSearchResponseItem } from './types'; +export type { + TokenSearchDiscoveryControllerMessenger, + TokenSearchDiscoveryControllerState, +} from './token-search-discovery-controller'; +export type { + TokenSearchResponseItem, + TokenTrendingResponseItem, + TokenSearchParams, + TrendingTokensParams, +} from './types'; export { AbstractTokenSearchApiService } from './token-search-api-service/abstract-token-search-api-service'; export { TokenSearchApiService } from './token-search-api-service/token-search-api-service'; + +export { AbstractTokenDiscoveryApiService } from './token-discovery-api-service/abstract-token-discovery-api-service'; +export { TokenDiscoveryApiService } from './token-discovery-api-service/token-discovery-api-service'; diff --git a/packages/token-search-discovery-controller/src/test/constants.ts b/packages/token-search-discovery-controller/src/test/constants.ts new file mode 100644 index 00000000000..20009c29e34 --- /dev/null +++ b/packages/token-search-discovery-controller/src/test/constants.ts @@ -0,0 +1,4 @@ +export const TEST_API_URLS = { + BASE_URL: 'https://mock-api.test', + PORTFOLIO_API: 'https://mock-portfolio-api.test', +} as const; diff --git a/packages/token-search-discovery-controller/src/token-discovery-api-service/abstract-token-discovery-api-service.ts b/packages/token-search-discovery-controller/src/token-discovery-api-service/abstract-token-discovery-api-service.ts new file mode 100644 index 00000000000..c676b91dd1d --- /dev/null +++ b/packages/token-search-discovery-controller/src/token-discovery-api-service/abstract-token-discovery-api-service.ts @@ -0,0 +1,17 @@ +import type { TokenTrendingResponseItem } from '../types'; + +/** + * Abstract class for fetching token discovery results. + */ +export abstract class AbstractTokenDiscoveryApiService { + /** + * Fetches trending tokens by chains from the portfolio API. + * + * @param params - Optional parameters including chains and limit + * @returns A promise resolving to an array of {@link TokenTrendingResponseItem} + */ + abstract getTrendingTokensByChains(params: { + chains?: string[]; + limit?: string; + }): Promise; +} diff --git a/packages/token-search-discovery-controller/src/token-discovery-api-service/token-discovery-api-service.test.ts b/packages/token-search-discovery-controller/src/token-discovery-api-service/token-discovery-api-service.test.ts new file mode 100644 index 00000000000..874f67463e1 --- /dev/null +++ b/packages/token-search-discovery-controller/src/token-discovery-api-service/token-discovery-api-service.test.ts @@ -0,0 +1,127 @@ +import nock, { cleanAll } from 'nock'; + +import { TokenDiscoveryApiService } from './token-discovery-api-service'; +import { TEST_API_URLS } from '../test/constants'; +import type { TokenTrendingResponseItem } from '../types'; + +describe('TokenDiscoveryApiService', () => { + let service: TokenDiscoveryApiService; + const mockTrendingResponse: TokenTrendingResponseItem[] = [ + { + chain_id: '1', + token_address: '0x123', + token_logo: 'https://example.com/logo.png', + token_name: 'Test Token', + token_symbol: 'TEST', + price_usd: 100, + token_age_in_days: 365, + on_chain_strength_index: 85, + security_score: 90, + market_cap: 1000000, + fully_diluted_valuation: 2000000, + twitter_followers: 50000, + holders_change: { + '1h': 10, + '1d': 100, + '1w': 1000, + '1M': 10000, + }, + liquidity_change_usd: { + '1h': 1000, + '1d': 10000, + '1w': 100000, + '1M': 1000000, + }, + experienced_net_buyers_change: { + '1h': 5, + '1d': 50, + '1w': 500, + '1M': 5000, + }, + volume_change_usd: { + '1h': 10000, + '1d': 100000, + '1w': 1000000, + '1M': 10000000, + }, + net_volume_change_usd: { + '1h': 5000, + '1d': 50000, + '1w': 500000, + '1M': 5000000, + }, + price_percent_change_usd: { + '1h': 1, + '1d': 10, + '1w': 20, + '1M': 30, + }, + }, + ]; + + beforeEach(() => { + service = new TokenDiscoveryApiService(TEST_API_URLS.PORTFOLIO_API); + }); + + afterEach(() => { + cleanAll(); + }); + + describe('constructor', () => { + it('should throw if baseUrl is empty', () => { + expect(() => new TokenDiscoveryApiService('')).toThrow( + 'Portfolio API URL is not set', + ); + }); + }); + + describe('getTrendingTokensByChains', () => { + it.each([ + { + params: { chains: ['1'], limit: '5' }, + expectedPath: '/tokens-search/trending-by-chains?chains=1&limit=5', + }, + { + params: { chains: ['1', '137'] }, + expectedPath: '/tokens-search/trending-by-chains?chains=1,137', + }, + { + params: { limit: '10' }, + expectedPath: '/tokens-search/trending-by-chains?limit=10', + }, + { + params: {}, + expectedPath: '/tokens-search/trending-by-chains', + }, + ])( + 'should construct correct URL for params: $params', + async ({ params, expectedPath }) => { + nock(TEST_API_URLS.PORTFOLIO_API) + .get(expectedPath) + .reply(200, mockTrendingResponse); + + const result = await service.getTrendingTokensByChains(params); + expect(result).toStrictEqual(mockTrendingResponse); + }, + ); + + it('should handle API errors', async () => { + nock(TEST_API_URLS.PORTFOLIO_API) + .get('/tokens-search/trending-by-chains') + .reply(500, 'Server Error'); + + await expect(service.getTrendingTokensByChains({})).rejects.toThrow( + 'Portfolio API request failed with status: 500', + ); + }); + + it('should return trending results', async () => { + nock(TEST_API_URLS.PORTFOLIO_API) + .get('/tokens-search/trending-by-chains') + .reply(200, mockTrendingResponse); + + const results = await service.getTrendingTokensByChains({}); + expect(results).toStrictEqual(mockTrendingResponse); + }); + }); +}); diff --git a/packages/token-search-discovery-controller/src/token-discovery-api-service/token-discovery-api-service.ts b/packages/token-search-discovery-controller/src/token-discovery-api-service/token-discovery-api-service.ts new file mode 100644 index 00000000000..c493dd6d80f --- /dev/null +++ b/packages/token-search-discovery-controller/src/token-discovery-api-service/token-discovery-api-service.ts @@ -0,0 +1,42 @@ +import { AbstractTokenDiscoveryApiService } from './abstract-token-discovery-api-service'; +import type { TokenTrendingResponseItem, TrendingTokensParams } from '../types'; + +export class TokenDiscoveryApiService extends AbstractTokenDiscoveryApiService { + readonly #baseUrl: string; + + constructor(baseUrl: string) { + super(); + if (!baseUrl) { + throw new Error('Portfolio API URL is not set'); + } + this.#baseUrl = baseUrl; + } + + async getTrendingTokensByChains( + trendingTokensParams: TrendingTokensParams, + ): Promise { + const url = new URL('/tokens-search/trending-by-chains', this.#baseUrl); + + if (trendingTokensParams.chains && trendingTokensParams.chains.length > 0) { + url.searchParams.append('chains', trendingTokensParams.chains.join()); + } + if (trendingTokensParams.limit) { + url.searchParams.append('limit', trendingTokensParams.limit); + } + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error( + `Portfolio API request failed with status: ${response.status}`, + ); + } + + return response.json(); + } +} diff --git a/packages/token-search-discovery-controller/src/token-search-api-service/token-search-api-service.test.ts b/packages/token-search-discovery-controller/src/token-search-api-service/token-search-api-service.test.ts index 077687cf504..6bcf7d54c45 100644 --- a/packages/token-search-discovery-controller/src/token-search-api-service/token-search-api-service.test.ts +++ b/packages/token-search-discovery-controller/src/token-search-api-service/token-search-api-service.test.ts @@ -1,60 +1,42 @@ +import nock, { cleanAll } from 'nock'; + import { TokenSearchApiService } from './token-search-api-service'; +import { TEST_API_URLS } from '../test/constants'; +import type { TokenSearchResponseItem } from '../types'; describe('TokenSearchApiService', () => { - const baseUrl = 'https://test-api'; let service: TokenSearchApiService; - let mockFetch: jest.SpyInstance; - - const mockResponses = { - allParams: [ - { - name: 'Token1', - symbol: 'TK1', - chainId: '1', - tokenAddress: '0x1', - usdPrice: 100, - usdPricePercentChange: { oneDay: 10 }, - }, - { - name: 'Token2', - symbol: 'TK2', - chainId: '1', - tokenAddress: '0x2', - usdPrice: 200, - usdPricePercentChange: { oneDay: 20 }, - }, - ], - onlyChain: [ - { - name: 'ChainToken', - symbol: 'CTK', - chainId: '1', - tokenAddress: '0x3', - usdPrice: 300, - usdPricePercentChange: { oneDay: 30 }, + const mockSearchResults: TokenSearchResponseItem[] = [ + { + name: 'Test Token', + symbol: 'TEST', + chainId: '1', + tokenAddress: '0x123', + usdPrice: 100, + usdPricePercentChange: { + oneDay: 10, }, - ], - onlyName: [ - { - name: 'NameMatch', - symbol: 'NM', - chainId: '1', - tokenAddress: '0x4', - usdPrice: 400, - usdPricePercentChange: { oneDay: 40 }, + logoUrl: 'https://example.com/logo.png', + }, + { + name: 'Another Token', + symbol: 'ANOT', + chainId: '137', + tokenAddress: '0x456', + usdPrice: 50, + usdPricePercentChange: { + oneDay: -5, }, - ], - }; + // logoUrl intentionally omitted to match API behavior + }, + ]; beforeEach(() => { - service = new TokenSearchApiService(baseUrl); - mockFetch = jest - .spyOn(global, 'fetch') - .mockResolvedValue(new Response(JSON.stringify([]), { status: 200 })); + service = new TokenSearchApiService(TEST_API_URLS.BASE_URL); }); afterEach(() => { - mockFetch.mockRestore(); + cleanAll(); }); describe('constructor', () => { @@ -66,86 +48,69 @@ describe('TokenSearchApiService', () => { }); describe('searchTokens', () => { - it.each([ - { - params: { chains: ['1'], name: 'Test', limit: '10' }, - expectedUrl: new URL( - `${baseUrl}/tokens-search/name?chains=1&name=Test&limit=10`, - ), - }, - { - params: { chains: ['1', '137'], name: 'Test' }, - expectedUrl: new URL( - `${baseUrl}/tokens-search/name?chains=1%2C137&name=Test`, - ), - }, - { - params: { name: 'Test' }, - expectedUrl: new URL(`${baseUrl}/tokens-search/name?name=Test`), - }, - { - params: { chains: ['1'] }, - expectedUrl: new URL(`${baseUrl}/tokens-search/name?chains=1`), - }, - { - params: { limit: '20' }, - expectedUrl: new URL(`${baseUrl}/tokens-search/name?limit=20`), - }, - { - params: {}, - expectedUrl: new URL(`${baseUrl}/tokens-search/name`), - }, - ])( - 'should construct correct URL for params: $params', - async ({ params, expectedUrl }) => { - await service.searchTokens(params); - expect(mockFetch.mock.calls[0][0].toString()).toBe( - expectedUrl.toString(), - ); - }, - ); + it('should return search results with all parameters', async () => { + nock(TEST_API_URLS.BASE_URL) + .get('/tokens-search') + .query({ + query: 'TEST', + chains: '1,137', + limit: '10', + }) + .reply(200, mockSearchResults); - it('should handle API errors', async () => { - mockFetch.mockResolvedValueOnce( - new Response('Server Error', { status: 500 }), + const results = await service.searchTokens({ + query: 'TEST', + chains: ['1', '137'], + limit: '10', + }); + expect(results).toStrictEqual(mockSearchResults); + }); + + it('should filter results by chain when only chains parameter is provided', async () => { + const chainSpecificResults = mockSearchResults.filter( + (token) => token.chainId === '137', ); + nock(TEST_API_URLS.BASE_URL) + .get('/tokens-search') + .query({ chains: '137' }) + .reply(200, chainSpecificResults); + + const results = await service.searchTokens({ chains: ['137'] }); + expect(results).toStrictEqual(chainSpecificResults); + }); + + it('should handle API errors', async () => { + nock(TEST_API_URLS.BASE_URL) + .get('/tokens-search') + .reply(500, 'Server Error'); + await expect(service.searchTokens({})).rejects.toThrow( 'Portfolio API request failed with status: 500', ); }); - }); - describe('searchTokens response handling', () => { - it.each([ - { - params: { chains: ['1'], name: 'Test', limit: '2' }, - mockResponse: mockResponses.allParams, - description: 'all parameters', - }, - { - params: { chains: ['1'] }, - mockResponse: mockResponses.onlyChain, - description: 'only chain parameter', - }, - { - params: { name: 'Name' }, - mockResponse: mockResponses.onlyName, - description: 'only name parameter', - }, - ])( - 'should handle response correctly regardless of params', - async ({ params, mockResponse }) => { - mockFetch = jest - .spyOn(global, 'fetch') - .mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }), - ); + it('should handle tokens with missing logoUrl', async () => { + const tokenWithoutLogo = { + name: 'No Logo Token', + symbol: 'NOLOG', + chainId: '1', + tokenAddress: '0x789', + usdPrice: 75, + usdPricePercentChange: { + oneDay: 2, + }, + // logoUrl intentionally omitted to match API behavior + }; - const response = await service.searchTokens(params); + nock(TEST_API_URLS.BASE_URL) + .get('/tokens-search') + .query({ query: 'NOLOG' }) + .reply(200, [tokenWithoutLogo]); - expect(response).toStrictEqual(mockResponse); - }, - ); + const results = await service.searchTokens({ query: 'NOLOG' }); + expect(results).toStrictEqual([tokenWithoutLogo]); + expect(results[0].logoUrl).toBeUndefined(); + }); }); }); diff --git a/packages/token-search-discovery-controller/src/token-search-api-service/token-search-api-service.ts b/packages/token-search-discovery-controller/src/token-search-api-service/token-search-api-service.ts index 04e888e950e..4cc1270065f 100644 --- a/packages/token-search-discovery-controller/src/token-search-api-service/token-search-api-service.ts +++ b/packages/token-search-discovery-controller/src/token-search-api-service/token-search-api-service.ts @@ -15,13 +15,13 @@ export class TokenSearchApiService extends AbstractTokenSearchApiService { async searchTokens( tokenSearchParams?: TokenSearchParams, ): Promise { - const url = new URL('/tokens-search/name', this.#baseUrl); + const url = new URL('/tokens-search', this.#baseUrl); if (tokenSearchParams?.chains && tokenSearchParams.chains.length > 0) { url.searchParams.append('chains', tokenSearchParams.chains.join()); } - if (tokenSearchParams?.name) { - url.searchParams.append('name', tokenSearchParams.name); + if (tokenSearchParams?.query) { + url.searchParams.append('query', tokenSearchParams.query); } if (tokenSearchParams?.limit) { url.searchParams.append('limit', tokenSearchParams.limit); 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 d7e237b9c4f..aa75cab4071 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,12 +1,16 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; +import { AbstractTokenDiscoveryApiService } from './token-discovery-api-service/abstract-token-discovery-api-service'; import { AbstractTokenSearchApiService } from './token-search-api-service/abstract-token-search-api-service'; import { getDefaultTokenSearchDiscoveryControllerState, TokenSearchDiscoveryController, } from './token-search-discovery-controller'; import type { TokenSearchDiscoveryControllerMessenger } from './token-search-discovery-controller'; -import type { TokenSearchResponseItem } from './types'; +import type { + TokenSearchResponseItem, + TokenTrendingResponseItem, +} from './types'; const controllerName = 'TokenSearchDiscoveryController'; @@ -16,8 +20,8 @@ const controllerName = 'TokenSearchDiscoveryController'; * @returns A restricted messenger for the TokenSearchDiscoveryController */ function getRestrictedMessenger() { - const controllerMessenger = new ControllerMessenger(); - return controllerMessenger.getRestricted({ + const messenger = new Messenger(); + return messenger.getRestricted({ name: controllerName, allowedActions: [], allowedEvents: [], @@ -38,16 +42,86 @@ describe('TokenSearchDiscoveryController', () => { }, ]; + const mockTrendingResults: TokenTrendingResponseItem[] = [ + { + chain_id: '1', + token_address: '0x123', + token_logo: 'https://example.com/logo.png', + token_name: 'Test Token', + token_symbol: 'TEST', + price_usd: 100, + token_age_in_days: 365, + on_chain_strength_index: 85, + security_score: 90, + market_cap: 1000000, + fully_diluted_valuation: 2000000, + twitter_followers: 50000, + holders_change: { + '1h': 10, + '1d': 100, + '1w': 1000, + '1M': 10000, + }, + liquidity_change_usd: { + '1h': 1000, + '1d': 10000, + '1w': 100000, + '1M': 1000000, + }, + experienced_net_buyers_change: { + '1h': 5, + '1d': 50, + '1w': 500, + '1M': 5000, + }, + volume_change_usd: { + '1h': 10000, + '1d': 100000, + '1w': 1000000, + '1M': 10000000, + }, + net_volume_change_usd: { + '1h': 5000, + '1d': 50000, + '1w': 500000, + '1M': 5000000, + }, + price_percent_change_usd: { + '1h': 1, + '1d': 10, + '1w': 20, + '1M': 30, + }, + }, + ]; + class MockTokenSearchService extends AbstractTokenSearchApiService { async searchTokens(): Promise { return mockSearchResults; } } + class MockTokenDiscoveryService extends AbstractTokenDiscoveryApiService { + async getTrendingTokensByChains(): Promise { + return mockTrendingResults; + } + } + + let mainController: TokenSearchDiscoveryController; + + beforeEach(() => { + mainController = new TokenSearchDiscoveryController({ + tokenSearchService: new MockTokenSearchService(), + tokenDiscoveryService: new MockTokenDiscoveryService(), + messenger: getRestrictedMessenger(), + }); + }); + describe('constructor', () => { it('should initialize with default state', () => { const controller = new TokenSearchDiscoveryController({ tokenSearchService: new MockTokenSearchService(), + tokenDiscoveryService: new MockTokenDiscoveryService(), messenger: getRestrictedMessenger(), }); @@ -64,47 +138,62 @@ describe('TokenSearchDiscoveryController', () => { const controller = new TokenSearchDiscoveryController({ tokenSearchService: new MockTokenSearchService(), + tokenDiscoveryService: new MockTokenDiscoveryService(), state: initialState, messenger: getRestrictedMessenger(), }); expect(controller.state).toStrictEqual(initialState); }); + }); - it('should merge to complete state', () => { - const partialState = { - recentSearches: mockSearchResults, - }; - - const controller = new TokenSearchDiscoveryController({ - tokenSearchService: new MockTokenSearchService(), - state: partialState, - messenger: getRestrictedMessenger(), - }); + describe('searchTokens', () => { + it('should return search results', async () => { + const results = await mainController.searchTokens({}); + expect(results).toStrictEqual(mockSearchResults); + }); + }); - expect(controller.state).toStrictEqual({ - ...getDefaultTokenSearchDiscoveryControllerState(), - ...partialState, - }); + describe('getTrendingTokens', () => { + it('should return trending results', async () => { + const results = await mainController.getTrendingTokens({}); + expect(results).toStrictEqual(mockTrendingResults); }); }); - describe('searchTokens', () => { - it('should update state with search results', async () => { - const mockService = new MockTokenSearchService(); - const controller = new TokenSearchDiscoveryController({ - tokenSearchService: mockService, + describe('error handling', () => { + class ErrorTokenSearchService extends AbstractTokenSearchApiService { + async searchTokens(): Promise { + return []; + } + } + + class ErrorTokenDiscoveryService extends AbstractTokenDiscoveryApiService { + async getTrendingTokensByChains(): Promise { + return []; + } + } + + it('should handle search service errors', async () => { + const errorController = new TokenSearchDiscoveryController({ + tokenSearchService: new ErrorTokenSearchService(), + tokenDiscoveryService: new MockTokenDiscoveryService(), messenger: getRestrictedMessenger(), }); - const response = await controller.searchTokens({ - chains: ['1'], - name: 'Test', + const results = await errorController.searchTokens({}); + expect(results).toStrictEqual([]); + }); + + it('should handle discovery service errors', async () => { + const errorController = new TokenSearchDiscoveryController({ + tokenSearchService: new MockTokenSearchService(), + tokenDiscoveryService: new ErrorTokenDiscoveryService(), + messenger: getRestrictedMessenger(), }); - expect(response).toStrictEqual(mockSearchResults); - expect(controller.state.recentSearches).toStrictEqual(mockSearchResults); - expect(controller.state.lastSearchTimestamp).toBeDefined(); + const results = await errorController.getTrendingTokens({}); + expect(results).toStrictEqual([]); }); }); }); 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 0f14962ffab..cf38137072b 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,12 +1,18 @@ import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; +import type { AbstractTokenDiscoveryApiService } from './token-discovery-api-service/abstract-token-discovery-api-service'; import type { AbstractTokenSearchApiService } from './token-search-api-service/abstract-token-search-api-service'; -import type { TokenSearchParams, TokenSearchResponseItem } from './types'; +import type { + TokenSearchParams, + TokenSearchResponseItem, + TokenTrendingResponseItem, + TrendingTokensParams, +} from './types'; // === GENERAL === @@ -74,14 +80,13 @@ type AllowedEvents = never; * The messenger which is restricted to actions and events accessed by * {@link TokenSearchDiscoveryController}. */ -export type TokenSearchDiscoveryControllerMessenger = - RestrictedControllerMessenger< - typeof controllerName, - TokenSearchDiscoveryControllerActions | AllowedActions, - TokenSearchDiscoveryControllerEvents | AllowedEvents, - AllowedActions['type'], - AllowedEvents['type'] - >; +export type TokenSearchDiscoveryControllerMessenger = RestrictedMessenger< + typeof controllerName, + TokenSearchDiscoveryControllerActions | AllowedActions, + TokenSearchDiscoveryControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; /** * Constructs the default {@link TokenSearchDiscoveryController} state. This allows @@ -100,7 +105,7 @@ export function getDefaultTokenSearchDiscoveryControllerState(): TokenSearchDisc /** * The TokenSearchDiscoveryController manages the retrieval of token search results and token discovery. - * It fetches token search results from the portfolio API. + * It fetches token search results and discovery data from the Portfolio API. */ export class TokenSearchDiscoveryController extends BaseController< typeof controllerName, @@ -109,12 +114,16 @@ export class TokenSearchDiscoveryController extends BaseController< > { readonly #tokenSearchService: AbstractTokenSearchApiService; + readonly #tokenDiscoveryService: AbstractTokenDiscoveryApiService; + constructor({ tokenSearchService, + tokenDiscoveryService, state = {}, messenger, }: { tokenSearchService: AbstractTokenSearchApiService; + tokenDiscoveryService: AbstractTokenDiscoveryApiService; state?: Partial; messenger: TokenSearchDiscoveryControllerMessenger; }) { @@ -126,6 +135,7 @@ export class TokenSearchDiscoveryController extends BaseController< }); this.#tokenSearchService = tokenSearchService; + this.#tokenDiscoveryService = tokenDiscoveryService; } async searchTokens( @@ -141,4 +151,10 @@ export class TokenSearchDiscoveryController extends BaseController< return results; } + + async getTrendingTokens( + params: TrendingTokensParams, + ): Promise { + return this.#tokenDiscoveryService.getTrendingTokensByChains(params); + } } diff --git a/packages/token-search-discovery-controller/src/types.ts b/packages/token-search-discovery-controller/src/types.ts index 1dbd9244336..ff8757951ff 100644 --- a/packages/token-search-discovery-controller/src/types.ts +++ b/packages/token-search-discovery-controller/src/types.ts @@ -1,6 +1,6 @@ export type TokenSearchParams = { chains?: string[]; - name?: string; + query?: string; limit?: string; }; @@ -13,4 +13,61 @@ export type TokenSearchResponseItem = { usdPricePercentChange: { oneDay: number; }; + logoUrl?: string; +}; + +export type TokenTrendingResponseItem = { + chain_id: string; + token_address: string; + token_logo: string; + token_name: string; + token_symbol: string; + price_usd: number; + token_age_in_days: number; + on_chain_strength_index: number; + security_score: number; + market_cap: number; + fully_diluted_valuation: number; + twitter_followers: number; + holders_change: { + '1h': number | null; + '1d': number | null; + '1w': number | null; + '1M': number | null; + }; + liquidity_change_usd: { + '1h': number | null; + '1d': number | null; + '1w': number | null; + '1M': number | null; + }; + experienced_net_buyers_change: { + '1h': number | null; + '1d': number | null; + '1w': number | null; + '1M': number | null; + }; + volume_change_usd: { + '1h': number | null; + '1d': number | null; + '1w': number | null; + '1M': number | null; + }; + net_volume_change_usd: { + '1h': number | null; + '1d': number | null; + '1w': number | null; + '1M': number | null; + }; + price_percent_change_usd: { + '1h': number | null; + '1d': number | null; + '1w': number | null; + '1M': number | null; + }; +}; + +export type TrendingTokensParams = { + chains?: string[]; + limit?: string; }; diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index e3de4de9635..af30d1018a4 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [46.0.0] + +### Added + +- Adds ability of re-simulating transaction depending on the `isActive` property on `transactionMeta` ([#5189](https://github.com/MetaMask/core/pull/5189)) + - `isActive` property is expected to set by client. + - Re-simulation of transactions will occur every 3 seconds if `isActive` is `true`. +- Adds `setTransactionActive` function to update the `isActive` property on `transactionMeta`. ([#5189](https://github.com/MetaMask/core/pull/5189)) + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^23.0.0` to `^24.0.0` ([#5318](https://github.com/MetaMask/core/pull/5318)) + +## [45.1.0] + +### Added + +- Add support for EIP-7702 / type 4 transactions ([#5285](https://github.com/MetaMask/core/pull/5285)) + - Add `setCode` to `TransactionEnvelopeType`. + - Add `authorizationList` to `TransactionParams`. + - Export `Authorization` and `AuthorizationList` types. + +### Changed + +- The TransactionController messenger must now allow the `KeyringController:signAuthorization` action ([#5285](https://github.com/MetaMask/core/pull/5285)) +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `ethereumjs/tx` from `^4.2.0` to `^5.4.0` ([#5285](https://github.com/MetaMask/core/pull/5285)) +- Bump `ethereumjs/common` from `^3.2.0` to `^4.5.0` ([#5285](https://github.com/MetaMask/core/pull/5285)) + +## [45.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^22.0.0` to `^23.0.0` ([#5292](https://github.com/MetaMask/core/pull/5292)) + +## [44.1.0] + +### Changed + +- Rename `ControllerMessenger` to `Messenger` ([#5234](https://github.com/MetaMask/core/pull/5234)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + +### Fixed + +- Prevent transaction resubmit on multiple endpoints ([#5262](https://github.com/MetaMask/core/pull/5262)) + +## [44.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^21.0.0` to `^22.0.0` ([#5218](https://github.com/MetaMask/core/pull/5218)) + ## [43.0.0] ### Added @@ -1238,7 +1290,12 @@ 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@43.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@46.0.0...HEAD +[46.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@45.1.0...@metamask/transaction-controller@46.0.0 +[45.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@45.0.0...@metamask/transaction-controller@45.1.0 +[45.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@44.1.0...@metamask/transaction-controller@45.0.0 +[44.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@44.0.0...@metamask/transaction-controller@44.1.0 +[44.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@43.0.0...@metamask/transaction-controller@44.0.0 [43.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@42.1.0...@metamask/transaction-controller@43.0.0 [42.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@42.0.0...@metamask/transaction-controller@42.1.0 [42.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@41.1.0...@metamask/transaction-controller@42.0.0 diff --git a/packages/transaction-controller/jest.config.js b/packages/transaction-controller/jest.config.js index e6f555ff0c9..3ae6e5e21b6 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: 94.76, + functions: 94.57, lines: 96.83, statements: 96.82, }, diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 91ff685b317..40057299f7b 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-controller", - "version": "43.0.0", + "version": "46.0.0", "description": "Stores transactions alongside their periodically updated statuses and manages interactions such as approval and cancellation", "keywords": [ "MetaMask", @@ -47,19 +47,19 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@ethereumjs/common": "^3.2.0", - "@ethereumjs/tx": "^4.2.0", + "@ethereumjs/common": "^4.4.0", + "@ethereumjs/tx": "^5.4.0", "@ethereumjs/util": "^8.1.0", "@ethersproject/abi": "^5.7.0", "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", - "@metamask/base-controller": "^7.1.1", - "@metamask/controller-utils": "^11.4.5", + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", "@metamask/eth-query": "^4.0.0", "@metamask/metamask-eth-abis": "^3.1.1", "@metamask/nonce-tracker": "^6.0.0", "@metamask/rpc-errors": "^7.0.2", - "@metamask/utils": "^11.0.1", + "@metamask/utils": "^11.1.0", "async-mutex": "^0.5.0", "bn.js": "^5.2.1", "eth-method-registry": "^4.0.0", @@ -69,14 +69,14 @@ }, "devDependencies": { "@babel/runtime": "^7.23.9", - "@metamask/accounts-controller": "^21.0.2", - "@metamask/approval-controller": "^7.1.2", + "@metamask/accounts-controller": "^24.0.0", + "@metamask/approval-controller": "^7.1.3", "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-block-tracker": "^11.0.3", - "@metamask/eth-json-rpc-provider": "^4.1.7", + "@metamask/eth-json-rpc-provider": "^4.1.8", "@metamask/ethjs-provider-http": "^0.3.0", - "@metamask/gas-fee-controller": "^22.0.2", - "@metamask/network-controller": "^22.1.1", + "@metamask/gas-fee-controller": "^22.0.3", + "@metamask/network-controller": "^22.2.1", "@types/bn.js": "^5.1.5", "@types/jest": "^27.4.1", "@types/node": "^16.18.54", @@ -92,7 +92,7 @@ }, "peerDependencies": { "@babel/runtime": "^7.0.0", - "@metamask/accounts-controller": "^21.0.0", + "@metamask/accounts-controller": "^24.0.0", "@metamask/approval-controller": "^7.0.0", "@metamask/eth-block-tracker": ">=9", "@metamask/gas-fee-controller": "^22.0.0", diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 44434e31e5e..4571fbe2014 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -5,7 +5,7 @@ import type { AddApprovalRequest, AddResult, } from '@metamask/approval-controller'; -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { ChainId, NetworkType, @@ -50,6 +50,7 @@ import { IncomingTransactionHelper } from './helpers/IncomingTransactionHelper'; import { MethodDataHelper } from './helpers/MethodDataHelper'; import { MultichainTrackingHelper } from './helpers/MultichainTrackingHelper'; import { PendingTransactionTracker } from './helpers/PendingTransactionTracker'; +import { shouldResimulate } from './helpers/ResimulateHelper'; import type { AllowedActions, AllowedEvents, @@ -86,14 +87,13 @@ import { getTransactionLayer1GasFee, updateTransactionLayer1GasFee, } from './utils/layer1-gas-fee-flow'; -import { shouldResimulate } from './utils/resimulate'; import { getSimulationData } from './utils/simulation'; import { updatePostTransactionBalance, updateSwapsTransaction, } from './utils/swaps'; -type UnrestrictedControllerMessenger = ControllerMessenger< +type UnrestrictedMessenger = Messenger< TransactionControllerActions | AllowedActions, TransactionControllerEvents | AllowedEvents >; @@ -115,7 +115,10 @@ jest.mock('./utils/gas'); jest.mock('./utils/gas-fees'); jest.mock('./utils/gas-flow'); jest.mock('./utils/layer1-gas-fee-flow'); -jest.mock('./utils/resimulate'); +jest.mock('./helpers/ResimulateHelper', () => ({ + ...jest.requireActual('./helpers/ResimulateHelper'), + shouldResimulate: jest.fn(), +})); jest.mock('./utils/simulation'); jest.mock('./utils/swaps'); jest.mock('uuid'); @@ -291,7 +294,7 @@ function buildMockGasFeeFlow(): jest.Mocked { * @returns A promise that resolves with the transaction meta when the transaction is finished. */ function waitForTransactionFinished( - messenger: ControllerMessenger< + messenger: Messenger< TransactionControllerActions | AllowedActions, TransactionControllerEvents | AllowedEvents >, @@ -388,7 +391,7 @@ const INTERNAL_ACCOUNT_MOCK: InternalAccount = { id: '58def058-d35f-49a1-a7ab-e2580565f6f5', address: ACCOUNT_MOCK, type: 'eip155:eoa', - scopes: ['eip155'], + scopes: ['eip155:0'], options: {}, methods: [], metadata: { @@ -591,8 +594,7 @@ describe('TransactionController', () => { listener(networkState); }); }; - const unrestrictedMessenger: UnrestrictedControllerMessenger = - new ControllerMessenger(); + const unrestrictedMessenger: UnrestrictedMessenger = new Messenger(); const getNetworkClientById = buildMockGetNetworkClientById( mockNetworkClientConfigurationsByNetworkClientId, ); @@ -694,7 +696,7 @@ describe('TransactionController', () => { * finally the mocked version of the action handler itself. */ function mockAddTransactionApprovalRequest( - messenger: UnrestrictedControllerMessenger, + messenger: UnrestrictedMessenger, options: | { state: 'approved'; @@ -2355,7 +2357,7 @@ describe('TransactionController', () => { try { await result; - } catch (error) { + } catch { // Ignore user rejected error as it is expected } await finishedPromise; @@ -6076,4 +6078,41 @@ describe('TransactionController', () => { ); }); }); + + describe('setTransactionActive', () => { + it('throws if transaction does not exist', async () => { + const { controller } = setupController(); + expect(() => controller.setTransactionActive('123', true)).toThrow( + 'Transaction with id 123 not found', + ); + }); + + it('updates the isActive state of a transaction', async () => { + const transactionId = '123'; + const { controller } = setupController({ + options: { + state: { + transactions: [ + { + id: transactionId, + status: TransactionStatus.unapproved, + history: [{}], + txParams: { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + } as unknown as TransactionMeta, + ], + }, + }, + updateToInitialState: true, + }); + + controller.setTransactionActive(transactionId, true); + + const transaction = controller.state.transactions[0]; + + expect(transaction?.isActive).toBe(true); + }); + }); }); diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index a92967f2105..60ffcb37e83 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -1,7 +1,4 @@ -import { Hardfork, Common, type ChainConfig } from '@ethereumjs/common'; import type { TypedTransaction } from '@ethereumjs/tx'; -import { TransactionFactory } from '@ethereumjs/tx'; -import { bufferToHex } from '@ethereumjs/util'; import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; import type { AcceptResultCallbacks, @@ -11,7 +8,7 @@ import type { import type { ControllerGetStateAction, ControllerStateChangeEvent, - RestrictedControllerMessenger, + RestrictedMessenger, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import { @@ -68,6 +65,12 @@ import { IncomingTransactionHelper } from './helpers/IncomingTransactionHelper'; import { MethodDataHelper } from './helpers/MethodDataHelper'; import { MultichainTrackingHelper } from './helpers/MultichainTrackingHelper'; import { PendingTransactionTracker } from './helpers/PendingTransactionTracker'; +import type { ResimulateResponse } from './helpers/ResimulateHelper'; +import { + ResimulateHelper, + hasSimulationDataChanged, + shouldResimulate, +} from './helpers/ResimulateHelper'; import { projectLogger as log } from './logger'; import type { DappSuggestedGasFees, @@ -94,6 +97,8 @@ import { TransactionStatus, SimulationErrorCode, } from './types'; +import type { KeyringControllerSignAuthorization } from './utils/eip7702'; +import { signAuthorizationList } from './utils/eip7702'; import { validateConfirmedExternalTransaction } from './utils/external-transactions'; import { addGasBuffer, estimateGas, updateGas } from './utils/gas'; import { updateGasFees } from './utils/gas-fees'; @@ -110,8 +115,7 @@ import { getAndFormatTransactionsForNonceTracker, getNextNonce, } from './utils/nonce'; -import type { ResimulateResponse } from './utils/resimulate'; -import { hasSimulationDataChanged, shouldResimulate } from './utils/resimulate'; +import { prepareTransaction, serializeTransaction } from './utils/prepare'; import { getTransactionParamsWithIncreasedGasFee } from './utils/retry'; import { getSimulationData } from './utils/simulation'; import { @@ -156,7 +160,6 @@ const metadata = { }, }; -export const HARDFORK = Hardfork.London; const SUBMIT_HISTORY_LIMIT = 100; /** @@ -338,10 +341,11 @@ const controllerName = 'TransactionController'; * The external actions available to the {@link TransactionController}. */ export type AllowedActions = + | AccountsControllerGetSelectedAccountAction | AddApprovalRequest + | KeyringControllerSignAuthorization | NetworkControllerFindNetworkClientIdByChainIdAction - | NetworkControllerGetNetworkClientByIdAction - | AccountsControllerGetSelectedAccountAction; + | NetworkControllerGetNetworkClientByIdAction; /** * The external events available to the {@link TransactionController}. @@ -539,7 +543,7 @@ export type TransactionControllerEvents = /** * The messenger of the {@link TransactionController}. */ -export type TransactionControllerMessenger = RestrictedControllerMessenger< +export type TransactionControllerMessenger = RestrictedMessenger< typeof controllerName, TransactionControllerActions | AllowedActions, TransactionControllerEvents | AllowedEvents, @@ -578,7 +582,7 @@ export class TransactionController extends BaseController< TransactionControllerState, TransactionControllerMessenger > { - #internalEvents = new EventEmitter(); + readonly #internalEvents = new EventEmitter(); private readonly isHistoryDisabled: boolean; @@ -588,7 +592,7 @@ export class TransactionController extends BaseController< private readonly approvingTransactionIds: Set = new Set(); - #methodDataHelper: MethodDataHelper; + readonly #methodDataHelper: MethodDataHelper; private readonly mutex = new Mutex(); @@ -617,9 +621,9 @@ export class TransactionController extends BaseController< chainId?: string, ) => NonceTrackerTransaction[]; - #incomingTransactionChainIds: Set = new Set(); + readonly #incomingTransactionChainIds: Set = new Set(); - #incomingTransactionHelper: IncomingTransactionHelper; + readonly #incomingTransactionHelper: IncomingTransactionHelper; private readonly layer1GasFeeFlows: Layer1GasFeeFlow[]; @@ -633,15 +637,15 @@ export class TransactionController extends BaseController< private readonly signAbortCallbacks: Map void> = new Map(); - #trace: TraceCallback; + readonly #trace: TraceCallback; - #transactionHistoryLimit: number; + readonly #transactionHistoryLimit: number; - #isFirstTimeInteractionEnabled: () => boolean; + readonly #isFirstTimeInteractionEnabled: () => boolean; - #isSimulationEnabled: () => boolean; + readonly #isSimulationEnabled: () => boolean; - #testGasFeeFlows: boolean; + readonly #testGasFeeFlows: boolean; private readonly afterSign: ( transactionMeta: TransactionMeta, @@ -716,7 +720,7 @@ export class TransactionController extends BaseController< ); } - #multichainTrackingHelper: MultichainTrackingHelper; + readonly #multichainTrackingHelper: MultichainTrackingHelper; /** * Method used to sign transactions @@ -926,6 +930,18 @@ export class TransactionController extends BaseController< this.#checkForPendingTransactionAndStartPolling, ); + new ResimulateHelper({ + simulateTransaction: this.#updateSimulationData.bind(this), + onTransactionsUpdate: (listener) => { + this.messagingSystem.subscribe( + 'TransactionController:stateChange', + listener, + (controllerState) => controllerState.transactions, + ); + }, + getTransactions: () => this.state.transactions, + }); + this.onBootCleanup(); this.#checkForPendingTransactionAndStartPolling(); } @@ -1016,20 +1032,25 @@ export class TransactionController extends BaseController< ); } - const isEIP1559Compatible = await this.getEIP1559Compatibility( - networkClientId, - ); + const permittedAddresses = + origin === undefined + ? undefined + : await this.getPermittedAccounts?.(origin); - validateTxParams(txParams, isEIP1559Compatible); + const selectedAddress = this.#getSelectedAccount().address; - if (origin && this.getPermittedAccounts) { - await validateTransactionOrigin( - await this.getPermittedAccounts(origin), - this.#getSelectedAccount().address, - txParams.from, - origin, - ); - } + await validateTransactionOrigin({ + from: txParams.from, + origin, + permittedAddresses, + selectedAddress, + txParams, + }); + + const isEIP1559Compatible = + await this.getEIP1559Compatibility(networkClientId); + + validateTxParams(txParams, isEIP1559Compatible); const dappSuggestedGasFees = this.generateDappSuggestedGasFees( txParams, @@ -1309,7 +1330,7 @@ export class TransactionController extends BaseController< prepareTransactionParams?.(newTxParams); - const unsignedEthTx = this.prepareUnsignedEthTx( + const unsignedEthTx = prepareTransaction( transactionMeta.chainId, newTxParams, ); @@ -1324,7 +1345,7 @@ export class TransactionController extends BaseController< signedTx, ); - const rawTx = bufferToHex(signedTx.serialize()); + const rawTx = serializeTransaction(signedTx); const newFee = newTxParams.maxFeePerGas ?? newTxParams.gasPrice; const oldFee = newTxParams.maxFeePerGas @@ -1857,6 +1878,33 @@ export class TransactionController extends BaseController< return this.getTransaction(txId); } + /** + * Update the isActive state of a transaction. + * + * @param transactionId - The ID of the transaction to update. + * @param isActive - The active state. + */ + setTransactionActive(transactionId: string, isActive: boolean) { + const transactionMeta = this.getTransaction(transactionId); + + if (!transactionMeta) { + throw new Error(`Transaction with id ${transactionId} not found`); + } + + this.#updateTransactionInternal( + { + transactionId, + note: 'TransactionController#setTransactionActive - Transaction isActive updated', + skipHistory: true, + skipValidation: true, + skipResimulateCheck: true, + }, + (updatedTransactionMeta) => { + updatedTransactionMeta.isActive = isActive; + }, + ); + } + /** * Signs and returns the raw transaction data for provided transaction params list. * @@ -1879,18 +1927,14 @@ export class TransactionController extends BaseController< const initialTx = listOfTxParams[0]; const { chainId } = initialTx; - const common = this.getCommonConfiguration(chainId); const networkClientId = this.#getNetworkClientId({ chainId }); - - const initialTxAsEthTx = TransactionFactory.fromTxData(initialTx, { - common, - }); - - const initialTxAsSerializedHex = bufferToHex(initialTxAsEthTx.serialize()); + const initialTxAsEthTx = prepareTransaction(chainId, initialTx); + const initialTxAsSerializedHex = serializeTransaction(initialTxAsEthTx); if (this.approvingTransactionIds.has(initialTxAsSerializedHex)) { return ''; } + this.approvingTransactionIds.add(initialTxAsSerializedHex); let rawTransactions, nonceLock; @@ -2199,14 +2243,15 @@ export class TransactionController extends BaseController< }; const { from } = updatedTransactionParams; - const common = this.getCommonConfiguration(chainId); - const unsignedTransaction = TransactionFactory.fromTxData( + + const unsignedTransaction = prepareTransaction( + chainId, updatedTransactionParams, - { common }, ); + const signedTransaction = await this.sign(unsignedTransaction, from); + const rawTransaction = serializeTransaction(signedTransaction); - const rawTransaction = bufferToHex(signedTransaction.serialize()); return rawTransaction; } @@ -2225,6 +2270,7 @@ export class TransactionController extends BaseController< /** * Stop the signing process for a specific transaction. * Throws an error causing the transaction status to be set to failed. + * * @param transactionId - The ID of the transaction to stop signing. */ abortTransactionSigning(transactionId: string) { @@ -2513,18 +2559,17 @@ export class TransactionController extends BaseController< note: 'TransactionController#approveTransaction - Transaction approved', }, (draftTxMeta) => { - const { txParams, chainId } = draftTxMeta; + const { chainId, txParams } = draftTxMeta; + const { gas, type } = txParams; draftTxMeta.status = TransactionStatus.approved; - draftTxMeta.txParams = { - ...txParams, - nonce, - chainId, - gasLimit: txParams.gas, - ...(isEIP1559Transaction(txParams) && { - type: TransactionEnvelopeType.feeMarket, - }), - }; + draftTxMeta.txParams.chainId = chainId; + draftTxMeta.txParams.gasLimit = gas; + draftTxMeta.txParams.nonce = nonce; + + if (!type && isEIP1559Transaction(txParams)) { + draftTxMeta.txParams.type = TransactionEnvelopeType.feeMarket; + } }, ); @@ -2669,8 +2714,6 @@ export class TransactionController extends BaseController< updatedTransactionMeta, ); this.#internalEvents.emit( - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `${transactionMeta.id}:finished`, updatedTransactionMeta, ); @@ -2706,8 +2749,6 @@ export class TransactionController extends BaseController< const { chainId, status, txParams, time } = tx; if (txParams) { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions const key = `${String(txParams.nonce)}-${convertHexToDecimal( chainId, )}-${new Date(time).toDateString()}`; @@ -2873,35 +2914,6 @@ export class TransactionController extends BaseController< }).provider; } - private prepareUnsignedEthTx( - chainId: Hex, - txParams: TransactionParams, - ): TypedTransaction { - return TransactionFactory.fromTxData(txParams, { - freeze: false, - common: this.getCommonConfiguration(chainId), - }); - } - - /** - * `@ethereumjs/tx` uses `@ethereumjs/common` as a configuration tool for - * specifying which chain, network, hardfork and EIPs to support for - * a transaction. By referencing this configuration, and analyzing the fields - * specified in txParams, @ethereumjs/tx is able to determine which EIP-2718 - * transaction type to use. - * - * @param chainId - The chainId to use for the configuration. - * @returns common configuration object - */ - private getCommonConfiguration(chainId: Hex): Common { - const customChainParams: Partial = { - chainId: parseInt(chainId, 16), - defaultHardfork: HARDFORK, - }; - - return Common.custom(customChainParams); - } - private onIncomingTransactions(transactions: TransactionMeta[]) { if (!transactions.length) { return; @@ -3157,9 +3169,18 @@ export class TransactionController extends BaseController< ): Promise { log('Signing transaction', txParams); - const unsignedEthTx = this.prepareUnsignedEthTx( + const { authorizationList, from } = txParams; + const finalTxParams = { ...txParams }; + + finalTxParams.authorizationList = await signAuthorizationList({ + authorizationList, + messenger: this.messagingSystem, + transactionMeta, + }); + + const unsignedEthTx = prepareTransaction( transactionMeta.chainId, - txParams, + finalTxParams, ); this.approvingTransactionIds.add(transactionMeta.id); @@ -3167,7 +3188,7 @@ export class TransactionController extends BaseController< const signedTx = await new Promise((resolve, reject) => { this.sign?.( unsignedEthTx, - txParams.from, + from, ...this.getAdditionalSignArguments(transactionMeta), ).then(resolve, reject); @@ -3207,7 +3228,7 @@ export class TransactionController extends BaseController< this.onTransactionStatusChange(transactionMetaWithRsv); - const rawTx = bufferToHex(signedTx.serialize()); + const rawTx = serializeTransaction(signedTx); const transactionMetaWithRawTx = merge({}, transactionMetaWithRsv, { rawTx, @@ -3320,10 +3341,12 @@ export class TransactionController extends BaseController< provider, blockTracker, chainId, + networkClientId, }: { provider: Provider; blockTracker: BlockTracker; chainId: Hex; + networkClientId: NetworkClientId; }): PendingTransactionTracker { const ethQuery = new EthQuery(provider); @@ -3331,6 +3354,7 @@ export class TransactionController extends BaseController< blockTracker, getChainId: () => chainId, getEthQuery: () => ethQuery, + getNetworkClientId: () => networkClientId, getTransactions: () => this.state.transactions, isResubmitEnabled: this.#pendingTransactionOptions.isResubmitEnabled, getGlobalLock: () => @@ -3353,7 +3377,7 @@ export class TransactionController extends BaseController< return pendingTransactionTracker; } - #checkForPendingTransactionAndStartPolling = () => { + readonly #checkForPendingTransactionAndStartPolling = () => { this.#multichainTrackingHelper.checkForPendingTransactionAndStartPolling(); }; @@ -3361,15 +3385,6 @@ export class TransactionController extends BaseController< this.#multichainTrackingHelper.stopAllTracking(); } - #removeIncomingTransactionHelperListeners( - incomingTransactionHelper: IncomingTransactionHelper, - ) { - incomingTransactionHelper.hub.removeAllListeners('transactions'); - incomingTransactionHelper.hub.removeAllListeners( - 'updated-last-fetched-timestamp', - ); - } - #addIncomingTransactionHelperListeners( incomingTransactionHelper: IncomingTransactionHelper, ) { diff --git a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts index 4bc2d89f4b7..f53bec63e90 100644 --- a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts +++ b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts @@ -5,7 +5,7 @@ import type { ApprovalControllerEvents, } from '@metamask/approval-controller'; import { ApprovalController } from '@metamask/approval-controller'; -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { ApprovalType, BUILT_IN_NETWORKS, @@ -64,7 +64,7 @@ jest.mock('uuid', () => { }; }); -type UnrestrictedControllerMessenger = ControllerMessenger< +type UnrestrictedMessenger = Messenger< | NetworkControllerActions | ApprovalControllerActions | TransactionControllerActions @@ -95,7 +95,7 @@ const createMockInternalAccount = ({ options: {}, methods: [], type: 'eip155:eoa', - scopes: ['eip155'], + scopes: ['eip155:0'], metadata: { name, keyring: { type: 'HD Key Tree' }, @@ -154,8 +154,7 @@ const setupController = async ( ], }); - const unrestrictedMessenger: UnrestrictedControllerMessenger = - new ControllerMessenger(); + const unrestrictedMessenger: UnrestrictedMessenger = new Messenger(); const networkController = new NetworkController({ messenger: unrestrictedMessenger.getRestricted({ name: 'NetworkController', @@ -163,6 +162,8 @@ const setupController = async ( allowedEvents: [], }), infuraProjectId, + fetch, + btoa, }); await networkController.initializeProvider(); const { provider, blockTracker } = diff --git a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts index 6b1cc4b9820..a4b80adb26e 100644 --- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts @@ -1,5 +1,3 @@ -import { Common, Hardfork } from '@ethereumjs/common'; -import { TransactionFactory } from '@ethereumjs/tx'; import { Contract } from '@ethersproject/contracts'; import { Web3Provider, type ExternalProvider } from '@ethersproject/providers'; import type { Hex } from '@metamask/utils'; @@ -13,6 +11,7 @@ import type { Layer1GasFeeFlowResponse, TransactionMeta, } from '../types'; +import { prepareTransaction } from '../utils/prepare'; const log = createModuleLogger(projectLogger, 'oracle-layer1-gas-fee-flow'); @@ -33,9 +32,9 @@ const GAS_PRICE_ORACLE_ABI = [ * Layer 1 gas fee flow that obtains gas fee estimate using an oracle smart contract. */ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { - #oracleAddress: Hex; + readonly #oracleAddress: Hex; - #signTransaction: boolean; + readonly #signTransaction: boolean; constructor(oracleAddress: Hex, signTransaction?: boolean) { this.#oracleAddress = oracleAddress; @@ -88,11 +87,9 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { sign: boolean, ) { const txParams = this.#buildTransactionParams(transactionMeta); - const common = this.#buildTransactionCommon(transactionMeta); + const { chainId } = transactionMeta; - let unserializedTransaction = TransactionFactory.fromTxData(txParams, { - common, - }); + let unserializedTransaction = prepareTransaction(chainId, txParams); if (sign) { const keyBuffer = Buffer.from(DUMMY_KEY, 'hex'); @@ -110,13 +107,4 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { gasLimit: transactionMeta.txParams.gas, }; } - - #buildTransactionCommon(transactionMeta: TransactionMeta) { - const chainId = Number(transactionMeta.chainId); - - return Common.custom({ - chainId, - defaultHardfork: Hardfork.London, - }); - } } diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts index 7edf55c4eae..61f39f7c510 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts @@ -28,7 +28,7 @@ const CONTROLLER_ARGS_MOCK: ConstructorParameters< type: 'eip155:eoa' as const, options: {}, methods: [], - scopes: ['eip155'], + scopes: ['eip155:0'], metadata: { name: 'Account 1', keyring: { type: 'HD Key Tree' }, diff --git a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts index 25687f1f5d7..90cede5650d 100644 --- a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts +++ b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts @@ -307,6 +307,7 @@ describe('MultichainTrackingHelper', () => { provider: MOCK_PROVIDERS.mainnet, blockTracker: MOCK_BLOCK_TRACKERS.mainnet, chainId: '0x1', + networkClientId: 'mainnet', }); expect(helper.has('mainnet')).toBe(true); diff --git a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts index f95900b1ea7..a75f8523b16 100644 --- a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts +++ b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts @@ -10,8 +10,8 @@ import type { NonceLock, NonceTracker } from '@metamask/nonce-tracker'; import type { Hex } from '@metamask/utils'; import { Mutex } from 'async-mutex'; -import { createModuleLogger, projectLogger } from '../logger'; import type { PendingTransactionTracker } from './PendingTransactionTracker'; +import { createModuleLogger, projectLogger } from '../logger'; /** * Registry of network clients provided by the NetworkController @@ -39,6 +39,7 @@ export type MultichainTrackingHelperOptions = { provider: Provider; blockTracker: BlockTracker; chainId: Hex; + networkClientId: NetworkClientId; }) => PendingTransactionTracker; onNetworkStateChange: ( listener: ( @@ -68,6 +69,7 @@ export class MultichainTrackingHelper { provider: Provider; blockTracker: BlockTracker; chainId: Hex; + networkClientId: NetworkClientId; }) => PendingTransactionTracker; readonly #nonceMutexesByChainId = new Map>(); @@ -319,6 +321,7 @@ export class MultichainTrackingHelper { provider, blockTracker, chainId, + networkClientId, }); this.#trackingMap.set(networkClientId, { diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts index a093ef5992c..712cd6f58d9 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts @@ -3,13 +3,14 @@ import type EthQuery from '@metamask/eth-query'; import type { BlockTracker } from '@metamask/network-controller'; import { freeze } from 'immer'; -import type { TransactionMeta } from '../types'; -import { TransactionStatus } from '../types'; import { PendingTransactionTracker } from './PendingTransactionTracker'; import { TransactionPoller } from './TransactionPoller'; +import type { TransactionMeta } from '../types'; +import { TransactionStatus } from '../types'; const ID_MOCK = 'testId'; const CHAIN_ID_MOCK = '0x1'; +const NETWORK_CLIENT_ID_MOCK = 'testNetworkClientId'; const NONCE_MOCK = '0x2'; const BLOCK_NUMBER_MOCK = '0x123'; @@ -18,6 +19,7 @@ const ETH_QUERY_MOCK = {} as unknown as EthQuery; const TRANSACTION_SUBMITTED_MOCK = { id: ID_MOCK, chainId: CHAIN_ID_MOCK, + networkClientId: NETWORK_CLIENT_ID_MOCK, hash: '0x1', rawTx: '0x987', status: TransactionStatus.submitted, @@ -114,6 +116,7 @@ describe('PendingTransactionTracker', () => { blockTracker, getChainId: jest.fn(() => CHAIN_ID_MOCK), getEthQuery: jest.fn(() => ETH_QUERY_MOCK), + getNetworkClientId: jest.fn(() => NETWORK_CLIENT_ID_MOCK), getTransactions: jest.fn(), getGlobalLock: jest.fn(() => Promise.resolve(jest.fn())), publishTransaction: jest.fn(), @@ -223,7 +226,7 @@ describe('PendingTransactionTracker', () => { }, { ...TRANSACTION_SUBMITTED_MOCK, - chainId: '0x2', + networkClientId: 'other-network-client-id', }, { ...TRANSACTION_SUBMITTED_MOCK, diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts index 557a5d7c302..0fe53ee3ebb 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts @@ -9,10 +9,10 @@ import type { import EventEmitter from 'events'; import { cloneDeep, merge } from 'lodash'; +import { TransactionPoller } from './TransactionPoller'; import { createModuleLogger, projectLogger } from '../logger'; import type { TransactionMeta, TransactionReceipt } from '../types'; import { TransactionStatus, TransactionType } from '../types'; -import { TransactionPoller } from './TransactionPoller'; /** * We wait this many blocks before emitting a 'transaction-dropped' event @@ -72,6 +72,8 @@ export class PendingTransactionTracker { #getEthQuery: (networkClientId?: NetworkClientId) => EthQuery; + readonly #getNetworkClientId: () => NetworkClientId; + #getTransactions: () => TransactionMeta[]; #isResubmitEnabled: () => boolean; @@ -101,6 +103,7 @@ export class PendingTransactionTracker { blockTracker, getChainId, getEthQuery, + getNetworkClientId, getTransactions, isResubmitEnabled, getGlobalLock, @@ -110,6 +113,7 @@ export class PendingTransactionTracker { blockTracker: BlockTracker; getChainId: () => string; getEthQuery: (networkClientId?: NetworkClientId) => EthQuery; + getNetworkClientId: () => string; getTransactions: () => TransactionMeta[]; isResubmitEnabled?: () => boolean; getGlobalLock: () => Promise<() => void>; @@ -129,10 +133,10 @@ export class PendingTransactionTracker { this.#droppedBlockCountByHash = new Map(); this.#getChainId = getChainId; this.#getEthQuery = getEthQuery; + this.#getNetworkClientId = getNetworkClientId; this.#getTransactions = getTransactions; this.#isResubmitEnabled = isResubmitEnabled ?? (() => true); this.#listener = this.#onLatestBlock.bind(this); - this.#log = createModuleLogger(log, getChainId()); this.#getGlobalLock = getGlobalLock; this.#publishTransaction = publishTransaction; this.#running = false; @@ -140,6 +144,11 @@ export class PendingTransactionTracker { this.#beforePublish = hooks?.beforePublish ?? (() => true); this.#beforeCheckPendingTransaction = hooks?.beforeCheckPendingTransaction ?? (() => true); + + this.#log = createModuleLogger( + log, + `${getChainId()}:${getNetworkClientId()}`, + ); } startIfPendingTransactions = () => { @@ -478,7 +487,7 @@ export class PendingTransactionTracker { #isNonceTaken(txMeta: TransactionMeta): boolean { const { id, txParams } = txMeta; - return this.#getCurrentChainTransactions().some( + return this.#getChainTransactions().some( (tx) => tx.id !== id && tx.txParams.from === txParams.from && @@ -489,7 +498,7 @@ export class PendingTransactionTracker { } #getPendingTransactions(): TransactionMeta[] { - return this.#getCurrentChainTransactions().filter( + return this.#getNetworkClientTransactions().filter( (tx) => tx.status === TransactionStatus.submitted && !tx.verifiedOnBlockchain && @@ -543,11 +552,15 @@ export class PendingTransactionTracker { return await query(this.#getEthQuery(), 'getTransactionCount', [address]); } - #getCurrentChainTransactions(): TransactionMeta[] { - const currentChainId = this.#getChainId(); + #getChainTransactions(): TransactionMeta[] { + const chainId = this.#getChainId(); + return this.#getTransactions().filter((tx) => tx.chainId === chainId); + } + #getNetworkClientTransactions(): TransactionMeta[] { + const networkClientId = this.#getNetworkClientId(); return this.#getTransactions().filter( - (tx) => tx.chainId === currentChainId, + (tx) => tx.networkClientId === networkClientId, ); } } diff --git a/packages/transaction-controller/src/utils/resimulate.test.ts b/packages/transaction-controller/src/helpers/ResimulateHelper.test.ts similarity index 70% rename from packages/transaction-controller/src/utils/resimulate.test.ts rename to packages/transaction-controller/src/helpers/ResimulateHelper.test.ts index f27e19498cd..9c372dd4264 100644 --- a/packages/transaction-controller/src/utils/resimulate.test.ts +++ b/packages/transaction-controller/src/helpers/ResimulateHelper.test.ts @@ -1,26 +1,27 @@ -/* eslint-disable @typescript-eslint/naming-convention */ import { NetworkType } from '@metamask/controller-utils'; +import type { NetworkClientId } from '@metamask/network-controller'; import { BN } from 'bn.js'; -import { CHAIN_IDS } from '../constants'; -import type { - SecurityAlertResponse, - SimulationData, - SimulationTokenBalanceChange, - TransactionMeta, -} from '../types'; -import { SimulationTokenStandard, TransactionStatus } from '../types'; import { + type ResimulateHelperOptions, + ResimulateHelper, BLOCK_TIME_ADDITIONAL_SECONDS, BLOCKAID_RESULT_TYPE_MALICIOUS, hasSimulationDataChanged, RESIMULATE_PARAMS, shouldResimulate, VALUE_COMPARISON_PERCENT_THRESHOLD, -} from './resimulate'; -import { getPercentageChange } from './utils'; - -jest.mock('./utils'); + RESIMULATE_INTERVAL_MS, +} from './ResimulateHelper'; +import { CHAIN_IDS } from '../constants'; +import type { + TransactionMeta, + SecurityAlertResponse, + SimulationData, + SimulationTokenBalanceChange, +} from '../types'; +import { TransactionStatus, SimulationTokenStandard } from '../types'; +import { getPercentageChange } from '../utils/utils'; const CURRENT_TIME_MOCK = 1234567890; const CURRENT_TIME_SECONDS_MOCK = 1234567; @@ -74,6 +75,139 @@ const TRANSACTION_META_MOCK: TransactionMeta = { }, }; +const mockTransactionMeta = { + id: '1', + networkClientId: 'network1' as NetworkClientId, + isActive: true, + status: TransactionStatus.unapproved, +} as TransactionMeta; + +jest.mock('../utils/utils'); + +describe('ResimulateHelper', () => { + let getTransactionsMock: jest.Mock<() => TransactionMeta[]>; + let simulateTransactionMock: jest.Mock< + (transactionMeta: TransactionMeta) => Promise + >; + let onTransactionsUpdateMock: jest.Mock<(listener: () => void) => void>; + + /** + * Triggers onStateChange callback + */ + function triggerStateChange() { + onTransactionsUpdateMock.mock.calls[0][0](); + } + + /** + * Mocks getTransactions to return given transactions argument + * + * @param transactions - Transactions to be returned + */ + function mockGetTransactionsOnce(transactions: TransactionMeta[]) { + getTransactionsMock.mockReturnValueOnce( + transactions as unknown as ResimulateHelperOptions['getTransactions'], + ); + } + + beforeEach(() => { + jest.useFakeTimers(); + getTransactionsMock = jest.fn(); + onTransactionsUpdateMock = jest.fn(); + simulateTransactionMock = jest.fn().mockResolvedValue(undefined); + + new ResimulateHelper({ + getTransactions: getTransactionsMock, + onTransactionsUpdate: onTransactionsUpdateMock, + simulateTransaction: simulateTransactionMock, + } as unknown as ResimulateHelperOptions); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it(`resimulates unapproved active transaction every ${RESIMULATE_INTERVAL_MS} milliseconds`, async () => { + mockGetTransactionsOnce([mockTransactionMeta]); + triggerStateChange(); + + jest.advanceTimersByTime(RESIMULATE_INTERVAL_MS); + await Promise.resolve(); + + jest.advanceTimersByTime(RESIMULATE_INTERVAL_MS); + await Promise.resolve(); + + jest.runAllTimers(); + + expect(simulateTransactionMock).toHaveBeenCalledWith(mockTransactionMeta); + expect(simulateTransactionMock).toHaveBeenCalledTimes(2); + }); + + it(`does not resimulate twice the same transaction even if state change is triggered twice`, async () => { + mockGetTransactionsOnce([mockTransactionMeta]); + triggerStateChange(); + + // Halfway through the interval + jest.advanceTimersByTime(RESIMULATE_INTERVAL_MS / 2); + + // Assume state change is triggered again + mockGetTransactionsOnce([mockTransactionMeta]); + triggerStateChange(); + + // Halfway through the interval + jest.advanceTimersByTime(RESIMULATE_INTERVAL_MS / 2); + + expect(simulateTransactionMock).toHaveBeenCalledTimes(1); + }); + + it('does not resimulate a transaction that is no longer active', () => { + mockGetTransactionsOnce([mockTransactionMeta]); + triggerStateChange(); + + // Halfway through the interval + jest.advanceTimersByTime(RESIMULATE_INTERVAL_MS / 2); + + const inactiveTransactionMeta = { + ...mockTransactionMeta, + isActive: false, + } as TransactionMeta; + + mockGetTransactionsOnce([inactiveTransactionMeta]); + triggerStateChange(); + + jest.advanceTimersByTime(RESIMULATE_INTERVAL_MS / 2); + + expect(simulateTransactionMock).toHaveBeenCalledTimes(0); + }); + + it('does not resimulate a transaction that is not active', () => { + const inactiveTransactionMeta = { + ...mockTransactionMeta, + isActive: false, + } as TransactionMeta; + + mockGetTransactionsOnce([inactiveTransactionMeta]); + triggerStateChange(); + + jest.advanceTimersByTime(2 * RESIMULATE_INTERVAL_MS); + + expect(simulateTransactionMock).toHaveBeenCalledTimes(0); + }); + + it('stops resimulating a transaction that is no longer in the transaction list', () => { + mockGetTransactionsOnce([mockTransactionMeta]); + triggerStateChange(); + + jest.advanceTimersByTime(RESIMULATE_INTERVAL_MS); + + mockGetTransactionsOnce([]); + triggerStateChange(); + + jest.advanceTimersByTime(RESIMULATE_INTERVAL_MS); + + expect(simulateTransactionMock).toHaveBeenCalledTimes(1); + }); +}); + describe('Resimulate Utils', () => { const getPercentageChangeMock = jest.mocked(getPercentageChange); diff --git a/packages/transaction-controller/src/utils/resimulate.ts b/packages/transaction-controller/src/helpers/ResimulateHelper.ts similarity index 70% rename from packages/transaction-controller/src/utils/resimulate.ts rename to packages/transaction-controller/src/helpers/ResimulateHelper.ts index b339356c7a2..6bb1a51c23c 100644 --- a/packages/transaction-controller/src/utils/resimulate.ts +++ b/packages/transaction-controller/src/helpers/ResimulateHelper.ts @@ -1,31 +1,142 @@ import type { Hex } from '@metamask/utils'; -import { createModuleLogger, remove0x } from '@metamask/utils'; +import { remove0x } from '@metamask/utils'; import { BN } from 'bn.js'; import { isEqual } from 'lodash'; -import { projectLogger } from '../logger'; +import { createModuleLogger, projectLogger } from '../logger'; +import { TransactionStatus } from '../types'; import type { SimulationBalanceChange, SimulationData, TransactionMeta, TransactionParams, } from '../types'; -import { getPercentageChange } from './utils'; +import { getPercentageChange } from '../utils/utils'; -const log = createModuleLogger(projectLogger, 'resimulate'); +const log = createModuleLogger(projectLogger, 'resimulate-helper'); export const RESIMULATE_PARAMS = ['to', 'value', 'data'] as const; export const BLOCKAID_RESULT_TYPE_MALICIOUS = 'Malicious'; export const VALUE_COMPARISON_PERCENT_THRESHOLD = 5; export const BLOCK_TIME_ADDITIONAL_SECONDS = 60; +export const RESIMULATE_INTERVAL_MS = 3000; export type ResimulateResponse = { blockTime?: number; resimulate: boolean; }; +export type ResimulateHelperOptions = { + getTransactions: () => TransactionMeta[]; + onTransactionsUpdate: (listener: () => void) => void; + simulateTransaction: (transactionMeta: TransactionMeta) => Promise; +}; + +export class ResimulateHelper { + // Map of transactionId <=> timeoutId + readonly #timeoutIds: Map = new Map(); + + readonly #getTransactions: () => TransactionMeta[]; + + readonly #simulateTransaction: ( + transactionMeta: TransactionMeta, + ) => Promise; + + constructor({ + getTransactions, + simulateTransaction, + onTransactionsUpdate, + }: ResimulateHelperOptions) { + this.#getTransactions = getTransactions; + this.#simulateTransaction = simulateTransaction; + + onTransactionsUpdate(this.#onTransactionsUpdate.bind(this)); + } + + #onTransactionsUpdate() { + const unapprovedTransactions = this.#getTransactions().filter( + (tx) => tx.status === TransactionStatus.unapproved, + ); + + const unapprovedTransactionIds = new Set( + unapprovedTransactions.map((tx) => tx.id), + ); + + // Combine unapproved transaction IDs and currently active resimulations + const allTransactionIds = new Set([ + ...unapprovedTransactionIds, + ...this.#timeoutIds.keys(), + ]); + + allTransactionIds.forEach((transactionId) => { + const transactionMeta = unapprovedTransactions.find( + (tx) => tx.id === transactionId, + ) as TransactionMeta; + + if (transactionMeta?.isActive) { + this.#start(transactionMeta); + } else { + this.#stop(transactionId); + } + }); + } + + #start(transactionMeta: TransactionMeta) { + const { id: transactionId } = transactionMeta; + if (this.#timeoutIds.has(transactionId)) { + return; + } + + const listener = () => { + // eslint-disable-next-line promise/catch-or-return + this.#simulateTransaction(transactionMeta) + .catch((error) => { + /* istanbul ignore next */ + log('Error during transaction resimulation', error); + }) + .finally(() => { + // Schedule the next execution + if (this.#timeoutIds.has(transactionId)) { + this.#queueUpdate(transactionId, listener); + } + }); + }; + + // Start the first execution + this.#queueUpdate(transactionId, listener); + log( + `Started resimulating transaction ${transactionId} every ${RESIMULATE_INTERVAL_MS} milliseconds`, + ); + } + + #queueUpdate(transactionId: string, listener: () => void) { + const timeoutId = setTimeout(listener, RESIMULATE_INTERVAL_MS); + this.#timeoutIds.set(transactionId, timeoutId); + } + + #stop(transactionId: string) { + if (!this.#timeoutIds.has(transactionId)) { + return; + } + + this.#removeListener(transactionId); + log( + `Stopped resimulating transaction ${transactionId} every ${RESIMULATE_INTERVAL_MS} milliseconds`, + ); + } + + #removeListener(id: string) { + const timeoutId = this.#timeoutIds.get(id); + if (timeoutId) { + clearTimeout(timeoutId); + this.#timeoutIds.delete(id); + } + } +} + /** * Determine if a transaction should be resimulated. + * * @param originalTransactionMeta - The original transaction metadata. * @param newTransactionMeta - The new transaction metadata. * @returns Whether the transaction should be resimulated. @@ -79,6 +190,7 @@ export function shouldResimulate( /** * Determine if the simulation data has changed. + * * @param originalSimulationData - The original simulation data. * @param newSimulationData - The new simulation data. * @returns Whether the simulation data has changed. @@ -141,6 +253,7 @@ export function hasSimulationDataChanged( /** * Determine if the transaction parameters have been updated. + * * @param originalTransactionMeta - The original transaction metadata. * @param newTransactionMeta - The new transaction metadata. * @returns Whether the transaction parameters have been updated. @@ -174,6 +287,7 @@ function isParametersUpdated( /** * Determine if a transaction has a new security alert. + * * @param originalTransactionMeta - The original transaction metadata. * @param newTransactionMeta - The new transaction metadata. * @returns Whether the transaction has a new security alert. @@ -205,6 +319,7 @@ function hasNewSecurityAlert( /** * Determine if a transaction has a value and simulation native balance mismatch. + * * @param originalTransactionMeta - The original transaction metadata. * @param newTransactionMeta - The new transaction metadata. * @returns Whether the transaction has a value and simulation native balance mismatch. @@ -240,6 +355,7 @@ function hasValueAndNativeBalanceMismatch( /** * Determine if a balance change has been updated. + * * @param originalBalanceChange - The original balance change. * @param newBalanceChange - The new balance change. * @returns Whether the balance change has been updated. @@ -258,6 +374,7 @@ function isBalanceChangeUpdated( /** * Determine if the percentage change between two values is within a threshold. + * * @param originalValue - The original value. * @param newValue - The new value. * @param originalNegative - Whether the original value is negative. diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index d2b3eeab45c..dcac9daa701 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -25,12 +25,13 @@ export type { TransactionControllerOptions, } from './TransactionController'; export { - HARDFORK, CANCEL_RATE, SPEED_UP_RATE, TransactionController, } from './TransactionController'; export type { + Authorization, + AuthorizationList, DappSuggestedGasFees, DefaultGasEstimates, FeeMarketEIP1559Values, @@ -81,3 +82,4 @@ export { } from './utils/utils'; export { CHAIN_IDS } from './constants'; export { SUPPORTED_CHAIN_IDS as INCOMING_TRANSACTIONS_SUPPORTED_CHAIN_IDS } from './helpers/AccountsApiRemoteTransactionSource'; +export { HARDFORK } from './utils/prepare'; diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index acf29eace65..33eecc9a9c0 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -9,8 +9,6 @@ import type { Operation } from 'fast-json-patch'; /** * Given a record, ensures that each property matches the `Json` type. */ -// TODO: Either fix this lint violation or explain why it's necessary to ignore. -// eslint-disable-next-line @typescript-eslint/naming-convention type MakeJsonCompatible = T extends Json ? T : { @@ -173,6 +171,11 @@ type TransactionMetaBase = { */ firstRetryBlockNumber?: string; + /** + * Whether the transaction is active. + */ + isActive?: boolean; + /** * Whether the transaction is the first time interaction. */ @@ -478,70 +481,52 @@ export enum TransactionStatus { /** * The initial state of a transaction before user approval. */ - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention unapproved = 'unapproved', /** * The transaction has been approved by the user but is not yet signed. * This status is usually brief but may be longer for scenarios like hardware wallet usage. */ - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention approved = 'approved', /** * The transaction is signed and in the process of being submitted to the network. * This status is typically short-lived but can be longer for certain cases, such as smart transactions. */ - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention signed = 'signed', /** * The transaction has been submitted to the network and is awaiting confirmation. */ - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention submitted = 'submitted', /** * The transaction has been successfully executed and confirmed on the blockchain. * This is a final state. */ - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention confirmed = 'confirmed', /** * The transaction encountered an error during execution on the blockchain and failed. * This is a final state. */ - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention failed = 'failed', /** * The transaction was superseded by another transaction, resulting in its dismissal. * This is a final state. */ - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention dropped = 'dropped', /** * The transaction was rejected by the user and not processed further. * This is a final state. */ - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention rejected = 'rejected', /** * @deprecated This status is no longer used. */ - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention cancelled = 'cancelled', } @@ -549,16 +534,11 @@ export enum TransactionStatus { * Options for wallet device. */ export enum WalletDevice { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention MM_MOBILE = 'metamask_mobile', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention MM_EXTENSION = 'metamask_extension', OTHER = 'other_device', } -/* eslint-disable @typescript-eslint/naming-convention */ /** * The type of the transaction. */ @@ -707,7 +687,6 @@ export enum TransactionType { */ tokenMethodIncreaseAllowance = 'increaseAllowance', } -/* eslint-enable @typescript-eslint/naming-convention */ /** * Standard data concerning a transaction to be processed by the blockchain. @@ -718,6 +697,13 @@ export type TransactionParams = { */ accessList?: AccessList; + /** + * Array of authorizations to set code on EOA accounts. + * Only supported in `setCode` transactions. + * Introduced in EIP-7702. + */ + authorizationList?: AuthorizationList; + /** * Network ID as per EIP-155. */ @@ -1009,8 +995,6 @@ export enum TransactionEnvelopeType { /** * A legacy transaction, the very first type. */ - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention legacy = '0x0', /** @@ -1018,8 +1002,6 @@ export enum TransactionEnvelopeType { * specifying the state that a transaction would act upon in advance and * theoretically save on gas fees. */ - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention accessList = '0x1', /** @@ -1030,9 +1012,14 @@ export enum TransactionEnvelopeType { * the maxPriorityFeePerGas (maximum amount of gwei per gas from the * transaction fee to distribute to miner). */ - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention feeMarket = '0x2', + + /** + * Adds code to externally owned accounts according to the signed authorizations + * in the new `authorizationList` parameter. + * Introduced in EIP-7702. + */ + setCode = '0x4', } /** @@ -1040,8 +1027,6 @@ export enum TransactionEnvelopeType { */ export enum UserFeeLevel { CUSTOM = 'custom', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention DAPP_SUGGESTED = 'dappSuggested', MEDIUM = 'medium', } @@ -1117,8 +1102,6 @@ export type TransactionError = { export type SecurityAlertResponse = { reason: string; features?: string[]; - // This is API specific hence naming convention is not followed. - // eslint-disable-next-line @typescript-eslint/naming-convention result_type: string; providerRequestsCount?: Record; }; @@ -1196,6 +1179,7 @@ export type GasFeeFlowResponse = { export type GasFeeFlow = { /** * Determine if the gas fee flow supports the specified transaction. + * * @param transactionMeta - The transaction metadata. * @returns Whether the gas fee flow supports the transaction. */ @@ -1203,6 +1187,7 @@ export type GasFeeFlow = { /** * Get gas fee estimates for a specific transaction. + * * @param request - The gas fee flow request. * @returns The gas fee flow response containing the gas fee estimates. */ @@ -1228,6 +1213,7 @@ export type Layer1GasFeeFlowResponse = { export type Layer1GasFeeFlow = { /** * Determine if the gas fee flow supports the specified transaction. + * * @param transactionMeta - The transaction metadata. * @returns Whether the layer1 gas fee flow supports the transaction. */ @@ -1235,6 +1221,7 @@ export type Layer1GasFeeFlow = { /** * Get layer 1 gas fee estimates for a specific transaction. + * * @param request - The gas fee flow request. * @returns The gas fee flow response containing the layer 1 gas fee estimate. */ @@ -1260,14 +1247,8 @@ export type SimulationBalanceChange = { /** Token standards supported by simulation. */ export enum SimulationTokenStandard { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention erc20 = 'erc20', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention erc721 = 'erc721', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention erc1155 = 'erc1155', } @@ -1373,3 +1354,40 @@ export type SubmitHistoryEntry = { export type InternalAccount = ReturnType< AccountsController['getSelectedAccount'] >; + +/** + * An authorization to be included in a `setCode` transaction. + * Specifies code to be added to the authorization signer's EOA account. + * Introduced in EIP-7702. + */ +export type Authorization = { + /** Address of a smart contract that contains the code to be set. */ + address: Hex; + + /** + * Specific chain the authorization applies to. + * If not provided, defaults to the chain ID of the transaction. + */ + chainId?: Hex; + + /** + * Nonce at which the authorization will be valid. + * If not provided, defaults to the nonce following the transaction's nonce. + */ + nonce?: Hex; + + /** R component of the signature. */ + r?: Hex; + + /** S component of the signature. */ + s?: Hex; + + /** Y parity generated from the signature. */ + yParity?: Hex; +}; + +/** + * An array of authorizations to be included in a `setCode` transaction. + * Introduced in EIP-7702. + */ +export type AuthorizationList = Authorization[]; diff --git a/packages/transaction-controller/src/utils/eip7702.test.ts b/packages/transaction-controller/src/utils/eip7702.test.ts new file mode 100644 index 00000000000..6db398b3ff8 --- /dev/null +++ b/packages/transaction-controller/src/utils/eip7702.test.ts @@ -0,0 +1,175 @@ +import type { KeyringControllerSignAuthorization } from './eip7702'; +import { signAuthorizationList } from './eip7702'; +import { Messenger } from '../../../base-controller/src'; +import type { TransactionControllerMessenger } from '../TransactionController'; +import type { AuthorizationList } from '../types'; +import { TransactionStatus, type TransactionMeta } from '../types'; + +const AUTHORIZATION_SIGNATURE_MOCK = + '0xf85c827a6994663f3ad617193148711d28f5334ee4ed070166028080a040e292da533253143f134643a03405f1af1de1d305526f44ed27e62061368d4ea051cfb0af34e491aa4d6796dececf95569088322e116c4b2f312bb23f20699269'; + +const AUTHORIZATION_SIGNATURE_2_MOCK = + '0x82d5b4845dfc808802480749c30b0e02d6d7817061ba141d2d1dcd520f9b65c59d0b985134dc2958a9981ce3b5d1061176313536e6da35852cfae41404f53ef31b624206f3bc543ca6710e02d58b909538d6e2445cea94dfd39737fbc0b3'; + +const TRANSACTION_META_MOCK: TransactionMeta = { + chainId: '0x1', + id: '123-456', + networkClientId: 'network-client-id', + status: TransactionStatus.unapproved, + time: 1234567890, + txParams: { + from: '0x', + nonce: '0x123', + }, +}; + +const AUTHORIZATION_LIST_MOCK: AuthorizationList = [ + { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x123', + nonce: '0x456', + }, +]; + +describe('EIP-7702 Utils', () => { + let baseMessenger: Messenger; + let controllerMessenger: TransactionControllerMessenger; + let signAuthorizationMock: jest.MockedFn< + KeyringControllerSignAuthorization['handler'] + >; + + beforeEach(() => { + baseMessenger = new Messenger(); + + signAuthorizationMock = jest + .fn() + .mockResolvedValue(AUTHORIZATION_SIGNATURE_MOCK); + + baseMessenger.registerActionHandler( + 'KeyringController:signAuthorization', + signAuthorizationMock, + ); + + controllerMessenger = baseMessenger.getRestricted({ + name: 'TransactionController', + allowedActions: ['KeyringController:signAuthorization'], + allowedEvents: [], + }); + }); + + describe('signAuthorizationList', () => { + it('returns undefined if no authorization list is provided', async () => { + expect( + await signAuthorizationList({ + authorizationList: undefined, + messenger: controllerMessenger, + transactionMeta: TRANSACTION_META_MOCK, + }), + ).toBeUndefined(); + }); + + it('populates signature properties', async () => { + const result = await signAuthorizationList({ + authorizationList: AUTHORIZATION_LIST_MOCK, + messenger: controllerMessenger, + transactionMeta: TRANSACTION_META_MOCK, + }); + + expect(result).toStrictEqual([ + { + address: AUTHORIZATION_LIST_MOCK[0].address, + chainId: AUTHORIZATION_LIST_MOCK[0].chainId, + nonce: AUTHORIZATION_LIST_MOCK[0].nonce, + r: '0xf85c827a6994663f3ad617193148711d28f5334ee4ed070166028080a040e292', + s: '0xda533253143f134643a03405f1af1de1d305526f44ed27e62061368d4ea051cf', + yParity: '0x1', + }, + ]); + }); + + it('populates signature properties for multiple authorizations', async () => { + signAuthorizationMock + .mockReset() + .mockResolvedValueOnce(AUTHORIZATION_SIGNATURE_MOCK) + .mockResolvedValueOnce(AUTHORIZATION_SIGNATURE_2_MOCK); + + const result = await signAuthorizationList({ + authorizationList: [ + AUTHORIZATION_LIST_MOCK[0], + AUTHORIZATION_LIST_MOCK[0], + ], + messenger: controllerMessenger, + transactionMeta: TRANSACTION_META_MOCK, + }); + + expect(result).toStrictEqual([ + { + address: AUTHORIZATION_LIST_MOCK[0].address, + chainId: AUTHORIZATION_LIST_MOCK[0].chainId, + nonce: AUTHORIZATION_LIST_MOCK[0].nonce, + r: '0xf85c827a6994663f3ad617193148711d28f5334ee4ed070166028080a040e292', + s: '0xda533253143f134643a03405f1af1de1d305526f44ed27e62061368d4ea051cf', + yParity: '0x1', + }, + { + address: AUTHORIZATION_LIST_MOCK[0].address, + chainId: AUTHORIZATION_LIST_MOCK[0].chainId, + nonce: AUTHORIZATION_LIST_MOCK[0].nonce, + r: '0x82d5b4845dfc808802480749c30b0e02d6d7817061ba141d2d1dcd520f9b65c5', + s: '0x9d0b985134dc2958a9981ce3b5d1061176313536e6da35852cfae41404f53ef3', + yParity: '0x', + }, + ]); + }); + + it('uses transaction chain ID if not specified', async () => { + const result = await signAuthorizationList({ + authorizationList: [ + { ...AUTHORIZATION_LIST_MOCK[0], chainId: undefined }, + ], + messenger: controllerMessenger, + transactionMeta: TRANSACTION_META_MOCK, + }); + + expect(result?.[0]?.chainId).toStrictEqual(TRANSACTION_META_MOCK.chainId); + }); + + it('uses transaction nonce + 1 if not specified', async () => { + const result = await signAuthorizationList({ + authorizationList: [ + { ...AUTHORIZATION_LIST_MOCK[0], nonce: undefined }, + ], + messenger: controllerMessenger, + transactionMeta: TRANSACTION_META_MOCK, + }); + + expect(result?.[0]?.nonce).toBe('0x124'); + }); + + it('uses incrementing transaction nonce for multiple authorizations if not specified', async () => { + const result = await signAuthorizationList({ + authorizationList: [ + { ...AUTHORIZATION_LIST_MOCK[0], nonce: undefined }, + { ...AUTHORIZATION_LIST_MOCK[0], nonce: undefined }, + { ...AUTHORIZATION_LIST_MOCK[0], nonce: undefined }, + ], + messenger: controllerMessenger, + transactionMeta: TRANSACTION_META_MOCK, + }); + + expect(result?.[0]?.nonce).toBe('0x124'); + expect(result?.[1]?.nonce).toBe('0x125'); + expect(result?.[2]?.nonce).toBe('0x126'); + }); + + it('normalizes nonce to 0x if zero', async () => { + const result = await signAuthorizationList({ + authorizationList: [{ ...AUTHORIZATION_LIST_MOCK[0], nonce: '0x0' }], + messenger: controllerMessenger, + transactionMeta: TRANSACTION_META_MOCK, + }); + + expect(result?.[0]?.nonce).toBe('0x'); + }); + }); +}); diff --git a/packages/transaction-controller/src/utils/eip7702.ts b/packages/transaction-controller/src/utils/eip7702.ts new file mode 100644 index 00000000000..67b29643946 --- /dev/null +++ b/packages/transaction-controller/src/utils/eip7702.ts @@ -0,0 +1,148 @@ +import { toHex } from '@metamask/controller-utils'; +import { createModuleLogger, type Hex } from '@metamask/utils'; + +import { projectLogger } from '../logger'; +import type { TransactionControllerMessenger } from '../TransactionController'; +import type { + Authorization, + AuthorizationList, + TransactionMeta, +} from '../types'; + +export type KeyringControllerAuthorization = [ + chainId: number, + contractAddress: string, + nonce: number, +]; + +export type KeyringControllerSignAuthorization = { + type: 'KeyringController:signAuthorization'; + handler: (authorization: KeyringControllerAuthorization) => Promise; +}; + +const log = createModuleLogger(projectLogger, 'eip-7702'); + +/** + * Sign an authorization list. + * + * @param options - Options bag. + * @param options.authorizationList - The authorization list to sign. + * @param options.messenger - The controller messenger. + * @param options.transactionMeta - The transaction metadata. + * @returns The signed authorization list. + */ +export async function signAuthorizationList({ + authorizationList, + messenger, + transactionMeta, +}: { + authorizationList?: AuthorizationList; + messenger: TransactionControllerMessenger; + transactionMeta: TransactionMeta; +}): Promise> { + if (!authorizationList) { + return undefined; + } + + const signedAuthorizationList: Required = []; + let index = 0; + + for (const authorization of authorizationList) { + const signedAuthorization = await signAuthorization( + authorization, + transactionMeta, + messenger, + index, + ); + + signedAuthorizationList.push(signedAuthorization); + index += 1; + } + + return signedAuthorizationList; +} + +/** + * Signs an authorization. + * + * @param authorization - The authorization to sign. + * @param transactionMeta - The associated transaction metadata. + * @param messenger - The messenger to use for signing. + * @param index - The index of the authorization in the list. + * @returns The signed authorization. + */ +async function signAuthorization( + authorization: Authorization, + transactionMeta: TransactionMeta, + messenger: TransactionControllerMessenger, + index: number, +): Promise> { + const finalAuthorization = prepareAuthorization( + authorization, + transactionMeta, + index, + ); + + const { address, chainId, nonce } = finalAuthorization; + const chainIdDecimal = parseInt(chainId, 16); + const nonceDecimal = parseInt(nonce, 16); + + const signature = await messenger.call( + 'KeyringController:signAuthorization', + [chainIdDecimal, address, nonceDecimal], + ); + + const r = signature.slice(0, 66) as Hex; + const s = `0x${signature.slice(66, 130)}` as Hex; + const v = parseInt(signature.slice(130, 132), 16); + const yParity = v - 27 === 0 ? '0x' : '0x1'; + const finalNonce = nonceDecimal === 0 ? '0x' : nonce; + + const result: Required = { + address, + chainId, + nonce: finalNonce, + r, + s, + yParity, + }; + + log('Signed authorization', result); + + return result; +} + +/** + * Prepares an authorization for signing by populating the chainId and nonce. + * + * @param authorization - The authorization to prepare. + * @param transactionMeta - The associated transaction metadata. + * @param index - The index of the authorization in the list. + * @returns The prepared authorization. + */ +function prepareAuthorization( + authorization: Authorization, + transactionMeta: TransactionMeta, + index: number, +): Authorization & { chainId: Hex; nonce: Hex } { + const { chainId: existingChainId, nonce: existingNonce } = authorization; + const { txParams, chainId: transactionChainId } = transactionMeta; + const { nonce: transactionNonce } = txParams; + + const chainId = existingChainId ?? transactionChainId; + let nonce = existingNonce; + + if (nonce === undefined) { + nonce = toHex(parseInt(transactionNonce as string, 16) + 1 + index); + } + + const result = { + ...authorization, + chainId, + nonce, + }; + + log('Prepared authorization', result); + + return result; +} diff --git a/packages/transaction-controller/src/utils/prepare.test.ts b/packages/transaction-controller/src/utils/prepare.test.ts new file mode 100644 index 00000000000..840e847482e --- /dev/null +++ b/packages/transaction-controller/src/utils/prepare.test.ts @@ -0,0 +1,67 @@ +import { FeeMarketEIP1559Transaction, LegacyTransaction } from '@ethereumjs/tx'; + +import { prepareTransaction, serializeTransaction } from './prepare'; +import { TransactionEnvelopeType, type TransactionParams } from '../types'; + +const CHAIN_ID_MOCK = '0x123'; + +const SERIALIZED_TRANSACTION = + '0xea808301234582012394123456789012345678901234567890123456789084123456788412345678808080'; + +const SERIALIZED_TRANSACTION_FEE_MARKET = + '0x02f4820123808401234567841234567882012394123456789012345678901234567890123456789084123456788412345678c0808080'; + +const TRANSACTION_PARAMS_MOCK: TransactionParams = { + data: '0x12345678', + from: '0x1234567890123456789012345678901234567890', + gasLimit: '0x123', + gasPrice: '0x12345', + to: '0x1234567890123456789012345678901234567890', + value: '0x12345678', +}; + +const TRANSACTION_PARAMS_FEE_MARKET_MOCK: TransactionParams = { + ...TRANSACTION_PARAMS_MOCK, + type: TransactionEnvelopeType.feeMarket, + maxFeePerGas: '0x12345678', + maxPriorityFeePerGas: '0x1234567', +}; + +describe('Prepare Utils', () => { + describe('prepareTransaction', () => { + it('returns legacy transaction object', () => { + const result = prepareTransaction(CHAIN_ID_MOCK, TRANSACTION_PARAMS_MOCK); + expect(result).toBeInstanceOf(LegacyTransaction); + }); + + it('returns fee market transaction object', () => { + const result = prepareTransaction( + CHAIN_ID_MOCK, + TRANSACTION_PARAMS_FEE_MARKET_MOCK, + ); + expect(result).toBeInstanceOf(FeeMarketEIP1559Transaction); + }); + }); + + describe('serializeTransaction', () => { + it('returns hex string for legacy transaction', () => { + const transaction = prepareTransaction( + CHAIN_ID_MOCK, + TRANSACTION_PARAMS_MOCK, + ); + + const result = serializeTransaction(transaction); + expect(result).toStrictEqual(SERIALIZED_TRANSACTION); + }); + + it('returns hex string for fee market transaction', () => { + const transaction = prepareTransaction( + CHAIN_ID_MOCK, + TRANSACTION_PARAMS_FEE_MARKET_MOCK, + ); + + const result = serializeTransaction(transaction); + expect(result).toStrictEqual(SERIALIZED_TRANSACTION_FEE_MARKET); + }); + }); +}); diff --git a/packages/transaction-controller/src/utils/prepare.ts b/packages/transaction-controller/src/utils/prepare.ts new file mode 100644 index 00000000000..4db930d3292 --- /dev/null +++ b/packages/transaction-controller/src/utils/prepare.ts @@ -0,0 +1,57 @@ +import type { ChainConfig } from '@ethereumjs/common'; +import { Common, Hardfork } from '@ethereumjs/common'; +import type { TypedTransaction, TypedTxData } from '@ethereumjs/tx'; +import { TransactionFactory } from '@ethereumjs/tx'; +import { bytesToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { TransactionParams } from '../types'; + +export const HARDFORK = Hardfork.Prague; + +/** + * Creates an `etheruemjs/tx` transaction object from the raw transaction parameters. + * + * @param chainId - Chain ID of the transaction. + * @param txParams - Transaction parameters. + * @returns The transaction object. + */ +export function prepareTransaction( + chainId: Hex, + txParams: TransactionParams, +): TypedTransaction { + // Does not allow `gasPrice` on type 4 transactions. + const data = txParams as TypedTxData; + + return TransactionFactory.fromTxData(data, { + freeze: false, + common: getCommonConfiguration(chainId), + }); +} + +/** + * Serializes a transaction object into a hex string. + * + * @param transaction - The transaction object. + * @returns The prefixed hex string. + */ +export function serializeTransaction(transaction: TypedTransaction) { + return bytesToHex(transaction.serialize()); +} + +/** + * Generates the configuration used to prepare transactions. + * + * @param chainId - Chain ID. + * @returns The common configuration. + */ +function getCommonConfiguration(chainId: Hex): Common { + const customChainParams: Partial = { + chainId: parseInt(chainId, 16), + defaultHardfork: HARDFORK, + }; + + return Common.custom(customChainParams, { + eips: [7702], + }); +} diff --git a/packages/transaction-controller/src/utils/swaps.test.ts b/packages/transaction-controller/src/utils/swaps.test.ts index 6b0d0dd26e1..7f84ed9f41f 100644 --- a/packages/transaction-controller/src/utils/swaps.test.ts +++ b/packages/transaction-controller/src/utils/swaps.test.ts @@ -1,4 +1,4 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { Messenger } from '@metamask/base-controller'; import { query } from '@metamask/controller-utils'; import { CHAIN_IDS } from '../constants'; @@ -45,7 +45,7 @@ describe('updateSwapsTransaction', () => { destinationTokenSymbol: 'DAI', }, }; - messenger = new ControllerMessenger< + messenger = new Messenger< TransactionControllerActions | AllowedActions, TransactionControllerEvents | AllowedEvents >().getRestricted({ diff --git a/packages/transaction-controller/src/utils/utils.ts b/packages/transaction-controller/src/utils/utils.ts index 360f5260dcd..ac61345e8df 100644 --- a/packages/transaction-controller/src/utils/utils.ts +++ b/packages/transaction-controller/src/utils/utils.ts @@ -1,3 +1,4 @@ +import type { AuthorizationList } from '@ethereumjs/common'; import { add0x, getKnownPropertyNames, @@ -20,6 +21,8 @@ export const ESTIMATE_GAS_ERROR = 'eth_estimateGas rpc method error'; // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const NORMALIZERS: { [param in keyof TransactionParams]: any } = { + authorizationList: (authorizationList?: AuthorizationList) => + authorizationList, data: (data: string) => add0x(padHexToEvenLength(data)), from: (from: string) => add0x(from).toLowerCase(), gas: (gas: string) => add0x(gas), @@ -83,8 +86,6 @@ export const validateGasValues = ( const value = (gasValues as any)[key]; if (typeof value !== 'string' || !isStrictHexString(value)) { throw new TypeError( - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `expected hex string for ${key} but received: ${value}`, ); } @@ -104,8 +105,6 @@ export function validateIfTransactionUnapproved( ) { if (transactionMeta?.status !== TransactionStatus.unapproved) { throw new Error( - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `TransactionsController: Can only call ${fnName} on an unapproved transaction.\n Current tx status: ${transactionMeta?.status}`, ); } diff --git a/packages/transaction-controller/src/utils/validation.test.ts b/packages/transaction-controller/src/utils/validation.test.ts index 8b6d65e5eec..91da8d9cc6e 100644 --- a/packages/transaction-controller/src/utils/validation.test.ts +++ b/packages/transaction-controller/src/utils/validation.test.ts @@ -1,8 +1,16 @@ +import { ORIGIN_METAMASK } from '@metamask/controller-utils'; import { rpcErrors } from '@metamask/rpc-errors'; +import { + validateParamTo, + validateTransactionOrigin, + validateTxParams, +} from './validation'; import { TransactionEnvelopeType } from '../types'; import type { TransactionParams } from '../types'; -import { validateTxParams } from './validation'; + +const FROM_MOCK = '0x1678a085c290ebd122dc42cba69373b5953b831d'; +const TO_MOCK = '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a'; describe('validation', () => { describe('validateTxParams', () => { @@ -10,7 +18,7 @@ describe('validation', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any expect(() => validateTxParams({ type: '0x3' } as any)).toThrow( rpcErrors.invalidParams( - 'Invalid transaction envelope type: "0x3". Must be one of: 0x0, 0x1, 0x2', + 'Invalid transaction envelope type: "0x3". Must be one of: 0x0, 0x1, 0x2, 0x4', ), ); }); @@ -44,7 +52,7 @@ describe('validation', () => { it('should throw if no data', () => { expect(() => validateTxParams({ - from: '0x3244e191f1b4903970224322180f1fbbc415696b', + from: FROM_MOCK, to: '0x', // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -53,7 +61,7 @@ describe('validation', () => { expect(() => validateTxParams({ - from: '0x3244e191f1b4903970224322180f1fbbc415696b', + from: FROM_MOCK, // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any), @@ -63,7 +71,7 @@ describe('validation', () => { it('should delete data', () => { const transaction = { data: 'foo', - from: '0x3244e191f1b4903970224322180f1fbbc415696b', + from: TO_MOCK, to: '0x', }; validateTxParams(transaction); @@ -73,7 +81,7 @@ describe('validation', () => { it('should throw if invalid to address', () => { expect(() => validateTxParams({ - from: '0x3244e191f1b4903970224322180f1fbbc415696b', + from: FROM_MOCK, to: '1337', // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -84,8 +92,8 @@ describe('validation', () => { it('should throw if value is invalid', () => { expect(() => validateTxParams({ - from: '0x3244e191f1b4903970224322180f1fbbc415696b', - to: '0x3244e191f1b4903970224322180f1fbbc415696b', + from: FROM_MOCK, + to: TO_MOCK, value: '133-7', // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -98,8 +106,8 @@ describe('validation', () => { expect(() => validateTxParams({ - from: '0x3244e191f1b4903970224322180f1fbbc415696b', - to: '0x3244e191f1b4903970224322180f1fbbc415696b', + from: FROM_MOCK, + to: TO_MOCK, value: '133.7', // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -112,8 +120,8 @@ describe('validation', () => { expect(() => validateTxParams({ - from: '0x3244e191f1b4903970224322180f1fbbc415696b', - to: '0x3244e191f1b4903970224322180f1fbbc415696b', + from: FROM_MOCK, + to: TO_MOCK, value: 'hello', // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -186,8 +194,8 @@ describe('validation', () => { it('throws if data is invalid', () => { expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', - to: '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a', + from: FROM_MOCK, + to: TO_MOCK, data: '0xa9059cbb00000000000000000000000011b6A5fE2906F3354145613DB0d99CEB51f604C90000000000000000000000000000000000000000000000004563918244F400', // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -200,7 +208,7 @@ describe('validation', () => { expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', + from: FROM_MOCK, value: '0x01', data: 'INVALID_ARGUMENT', // TODO: Replace `any` with type @@ -213,8 +221,8 @@ describe('validation', () => { it('throws if gasPrice is defined but type is feeMarket', () => { expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', - to: '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a', + from: FROM_MOCK, + to: TO_MOCK, gasPrice: '0x01', type: TransactionEnvelopeType.feeMarket, // TODO: Replace `any` with type @@ -227,8 +235,34 @@ describe('validation', () => { ); expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', - to: '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a', + from: FROM_MOCK, + to: TO_MOCK, + gasPrice: '0x01', + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any), + ).not.toThrow(); + }); + + it('throws if gasPrice is defined but type is setCode', () => { + expect(() => + validateTxParams({ + from: FROM_MOCK, + to: TO_MOCK, + gasPrice: '0x01', + type: TransactionEnvelopeType.setCode, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any), + ).toThrow( + rpcErrors.invalidParams( + 'Invalid transaction envelope type: specified type "0x4" but included a gasPrice instead of maxFeePerGas and maxPriorityFeePerGas', + ), + ); + expect(() => + validateTxParams({ + from: FROM_MOCK, + to: TO_MOCK, gasPrice: '0x01', // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -239,8 +273,8 @@ describe('validation', () => { it('throws if gasPrice is defined along with maxFeePerGas or maxPriorityFeePerGas', () => { expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', - to: '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a', + from: FROM_MOCK, + to: TO_MOCK, gasPrice: '0x01', maxFeePerGas: '0x01', // TODO: Replace `any` with type @@ -254,8 +288,8 @@ describe('validation', () => { expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', - to: '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a', + from: FROM_MOCK, + to: TO_MOCK, gasPrice: '0x01', maxPriorityFeePerGas: '0x01', // TODO: Replace `any` with type @@ -268,46 +302,46 @@ describe('validation', () => { ); }); - it('throws if gasPrice, maxPriorityFeePerGas or maxFeePerGas is not a valid hexadecimal', () => { + it('throws if gasPrice, maxPriorityFeePerGas or maxFeePerGas is not a valid hexadecimal string', () => { expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', - to: '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a', + from: FROM_MOCK, + to: TO_MOCK, gasPrice: 1, // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any), ).toThrow( rpcErrors.invalidParams( - 'Invalid transaction params: gasPrice is not a valid hexadecimal. got: (1)', + 'Invalid transaction params: gasPrice is not a valid hexadecimal string. got: (1)', ), ); expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', - to: '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a', + from: FROM_MOCK, + to: TO_MOCK, maxPriorityFeePerGas: 1, // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any), ).toThrow( rpcErrors.invalidParams( - 'Invalid transaction params: maxPriorityFeePerGas is not a valid hexadecimal. got: (1)', + 'Invalid transaction params: maxPriorityFeePerGas is not a valid hexadecimal string. got: (1)', ), ); expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', - to: '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a', + from: FROM_MOCK, + to: TO_MOCK, maxFeePerGas: 1, // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any), ).toThrow( rpcErrors.invalidParams( - 'Invalid transaction params: maxFeePerGas is not a valid hexadecimal. got: (1)', + 'Invalid transaction params: maxFeePerGas is not a valid hexadecimal string. got: (1)', ), ); }); @@ -315,8 +349,8 @@ describe('validation', () => { it('throws if maxPriorityFeePerGas is defined but type is not feeMarket', () => { expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', - to: '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a', + from: FROM_MOCK, + to: TO_MOCK, maxPriorityFeePerGas: '0x01', type: TransactionEnvelopeType.accessList, // TODO: Replace `any` with type @@ -324,13 +358,13 @@ describe('validation', () => { } as any), ).toThrow( rpcErrors.invalidParams( - 'Invalid transaction envelope type: specified type "0x1" but including maxFeePerGas and maxPriorityFeePerGas requires type: "0x2"', + 'Invalid transaction envelope type: specified type "0x1" but including maxFeePerGas and maxPriorityFeePerGas requires type: "0x2, 0x4"', ), ); expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', - to: '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a', + from: FROM_MOCK, + to: TO_MOCK, maxPriorityFeePerGas: '0x01', // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -341,8 +375,8 @@ describe('validation', () => { it('throws if maxFeePerGas is defined but type is not feeMarket', () => { expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', - to: '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a', + from: FROM_MOCK, + to: TO_MOCK, maxFeePerGas: '0x01', type: TransactionEnvelopeType.accessList, // TODO: Replace `any` with type @@ -350,13 +384,13 @@ describe('validation', () => { } as any), ).toThrow( rpcErrors.invalidParams( - 'Invalid transaction envelope type: specified type "0x1" but including maxFeePerGas and maxPriorityFeePerGas requires type: "0x2"', + 'Invalid transaction envelope type: specified type "0x1" but including maxFeePerGas and maxPriorityFeePerGas requires type: "0x2, 0x4"', ), ); expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', - to: '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a', + from: FROM_MOCK, + to: TO_MOCK, maxFeePerGas: '0x01', // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -367,8 +401,8 @@ describe('validation', () => { it('throws if gasLimit is defined but not a valid hexadecimal', () => { expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', - to: '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a', + from: FROM_MOCK, + to: TO_MOCK, maxFeePerGas: '0x01', gasLimit: 'zzzzz', // TODO: Replace `any` with type @@ -376,13 +410,13 @@ describe('validation', () => { } as any), ).toThrow( rpcErrors.invalidParams( - 'Invalid transaction params: gasLimit is not a valid hexadecimal. got: (zzzzz)', + 'Invalid transaction params: gasLimit is not a valid hexadecimal string. got: (zzzzz)', ), ); expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', - to: '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a', + from: FROM_MOCK, + to: TO_MOCK, maxFeePerGas: '0x01', gasLimit: '0x0', // TODO: Replace `any` with type @@ -394,29 +428,248 @@ describe('validation', () => { it('throws if gas is defined but not a valid hexadecimal', () => { expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', - to: '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a', + from: FROM_MOCK, + to: TO_MOCK, maxFeePerGas: '0x01', gas: 'zzzzz', // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any } as unknown as TransactionParams), ).toThrow( rpcErrors.invalidParams( - 'Invalid transaction params: gas is not a valid hexadecimal. got: (zzzzz)', + 'Invalid transaction params: gas is not a valid hexadecimal string. got: (zzzzz)', ), ); expect(() => validateTxParams({ - from: '0x1678a085c290ebd122dc42cba69373b5953b831d', - to: '0xfbb5595c18ca76bab52d66188e4ca50c7d95f77a', + from: FROM_MOCK, + to: TO_MOCK, maxFeePerGas: '0x01', gas: '0x0', // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any } as unknown as TransactionParams), ).not.toThrow(); }); }); + + describe('authorizationList', () => { + it('throws if type is not 0x4', () => { + expect(() => + validateTxParams({ + authorizationList: [], + from: FROM_MOCK, + to: TO_MOCK, + type: TransactionEnvelopeType.feeMarket, + }), + ).toThrow( + rpcErrors.invalidParams( + 'Invalid transaction envelope type: specified type "0x2" but including authorizationList requires type: "0x4"', + ), + ); + }); + + it('throws if not array', () => { + expect(() => + validateTxParams({ + authorizationList: 123 as never, + from: FROM_MOCK, + to: TO_MOCK, + type: TransactionEnvelopeType.setCode, + }), + ).toThrow( + rpcErrors.invalidParams( + 'Invalid transaction params: authorizationList must be an array', + ), + ); + }); + + it('throws if address missing', () => { + expect(() => + validateTxParams({ + authorizationList: [ + { + address: undefined as never, + }, + ], + from: FROM_MOCK, + to: TO_MOCK, + type: TransactionEnvelopeType.setCode, + }), + ).toThrow( + rpcErrors.invalidParams( + 'Invalid transaction params: address is not a valid hexadecimal string. got: (undefined)', + ), + ); + }); + + it('throws if address not hexadecimal string', () => { + expect(() => + validateTxParams({ + authorizationList: [ + { + address: 'test' as never, + }, + ], + from: FROM_MOCK, + to: TO_MOCK, + type: TransactionEnvelopeType.setCode, + }), + ).toThrow( + rpcErrors.invalidParams( + 'Invalid transaction params: address is not a valid hexadecimal string. got: (test)', + ), + ); + }); + + it('throws if address wrong length', () => { + expect(() => + validateTxParams({ + authorizationList: [ + { + address: FROM_MOCK.slice(0, -2) as never, + }, + ], + from: FROM_MOCK, + to: TO_MOCK, + type: TransactionEnvelopeType.setCode, + }), + ).toThrow( + rpcErrors.invalidParams( + 'Invalid transaction params: address must be 20 bytes. got: 19 bytes', + ), + ); + }); + + it.each(['chainId', 'nonce', 'r', 's', 'yParity'])( + 'throws if %s provided but not hexadecimal', + (property) => { + expect(() => + validateTxParams({ + authorizationList: [ + { + address: FROM_MOCK, + [property]: 'test' as never, + }, + ], + from: FROM_MOCK, + to: TO_MOCK, + type: TransactionEnvelopeType.setCode, + }), + ).toThrow( + rpcErrors.invalidParams( + `Invalid transaction params: ${property} is not a valid hexadecimal string. got: (test)`, + ), + ); + }, + ); + }); + }); + + describe('validateTransactionOrigin', () => { + it('throws if internal and from address not selected', async () => { + await expect( + validateTransactionOrigin({ + from: FROM_MOCK, + origin: ORIGIN_METAMASK, + permittedAddresses: undefined, + selectedAddress: '0x123', + txParams: {} as TransactionParams, + }), + ).rejects.toThrow( + rpcErrors.invalidParams( + 'Internally initiated transaction is using invalid account.', + ), + ); + }); + + it('does not throw if internal and from address is selected', async () => { + expect( + await validateTransactionOrigin({ + from: FROM_MOCK, + origin: ORIGIN_METAMASK, + permittedAddresses: undefined, + selectedAddress: FROM_MOCK, + txParams: {} as TransactionParams, + }), + ).toBeUndefined(); + }); + + it('throws if external and from not permitted', async () => { + await expect( + validateTransactionOrigin({ + from: FROM_MOCK, + origin: 'test-origin', + permittedAddresses: ['0x123', '0x456'], + selectedAddress: '0x123', + txParams: {} as TransactionParams, + }), + ).rejects.toThrow( + rpcErrors.invalidParams( + 'The requested account and/or method has not been authorized by the user.', + ), + ); + }); + + it('does not throw if external and from is permitted', async () => { + expect( + await validateTransactionOrigin({ + from: FROM_MOCK, + origin: 'test-origin', + permittedAddresses: ['0x123', FROM_MOCK], + selectedAddress: '0x123', + txParams: {} as TransactionParams, + }), + ).toBeUndefined(); + }); + + it('throw if external and type 4', async () => { + await expect( + validateTransactionOrigin({ + from: FROM_MOCK, + origin: 'test-origin', + permittedAddresses: [FROM_MOCK], + selectedAddress: '0x123', + txParams: { + type: TransactionEnvelopeType.setCode, + } as TransactionParams, + }), + ).rejects.toThrow( + rpcErrors.invalidParams( + 'External EIP-7702 transactions are not supported', + ), + ); + }); + + it('throw if external and authorization list provided', async () => { + await expect( + validateTransactionOrigin({ + from: FROM_MOCK, + origin: 'test-origin', + permittedAddresses: [FROM_MOCK], + selectedAddress: '0x123', + txParams: { + authorizationList: [], + from: FROM_MOCK, + } as TransactionParams, + }), + ).rejects.toThrow( + rpcErrors.invalidParams( + 'External EIP-7702 transactions are not supported', + ), + ); + }); + }); + + describe('validateParamTo', () => { + it('throws if no type', () => { + expect(() => validateParamTo(undefined as never)).toThrow( + rpcErrors.invalidParams('Invalid "to" address'), + ); + }); + + it('throws if type is not string', () => { + expect(() => validateParamTo(123 as never)).toThrow( + rpcErrors.invalidParams('Invalid "to" address'), + ); + }); }); }); diff --git a/packages/transaction-controller/src/utils/validation.ts b/packages/transaction-controller/src/utils/validation.ts index fbef756b319..caee53dc454 100644 --- a/packages/transaction-controller/src/utils/validation.ts +++ b/packages/transaction-controller/src/utils/validation.ts @@ -2,10 +2,16 @@ import { Interface } from '@ethersproject/abi'; import { ORIGIN_METAMASK, isValidHexAddress } from '@metamask/controller-utils'; import { abiERC20 } from '@metamask/metamask-eth-abis'; import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; -import { isStrictHexString } from '@metamask/utils'; +import { isStrictHexString, remove0x } from '@metamask/utils'; -import { TransactionEnvelopeType, type TransactionParams } from '../types'; import { isEIP1559Transaction } from './utils'; +import type { Authorization } from '../types'; +import { TransactionEnvelopeType, type TransactionParams } from '../types'; + +const TRANSACTION_ENVELOPE_TYPES_FEE_MARKET = [ + TransactionEnvelopeType.feeMarket, + TransactionEnvelopeType.setCode, +]; type GasFieldsToValidate = | 'gasPrice' @@ -17,37 +23,54 @@ type GasFieldsToValidate = /** * Validates whether a transaction initiated by a specific 'from' address is permitted by the origin. * - * @param permittedAddresses - The permitted accounts for the given origin. - * @param selectedAddress - The currently selected Ethereum address in the wallet. - * @param from - The address from which the transaction is initiated. - * @param origin - The origin or source of the transaction. + * @param options - Options bag. + * @param options.from - The address from which the transaction is initiated. + * @param options.origin - The origin or source of the transaction. + * @param options.permittedAddresses - The permitted accounts for the given origin. + * @param options.selectedAddress - The currently selected Ethereum address in the wallet. + * @param options.txParams - The transaction parameters. * @throws Throws an error if the transaction is not permitted. */ -export async function validateTransactionOrigin( - permittedAddresses: string[], - selectedAddress: string, - from: string, - origin: string, -) { - if (origin === ORIGIN_METAMASK) { - // Ensure the 'from' address matches the currently selected address - if (from !== selectedAddress) { - throw rpcErrors.internal({ - message: `Internally initiated transaction is using invalid account.`, - data: { - origin, - fromAddress: from, - selectedAddress, - }, - }); - } - return; +export async function validateTransactionOrigin({ + from, + origin, + permittedAddresses, + selectedAddress, + txParams, +}: { + from: string; + origin?: string; + permittedAddresses?: string[]; + selectedAddress: string; + txParams: TransactionParams; +}) { + const isInternal = origin === ORIGIN_METAMASK; + const isExternal = origin && origin !== ORIGIN_METAMASK; + const { authorizationList, type } = txParams; + + if (isInternal && from !== selectedAddress) { + throw rpcErrors.internal({ + message: `Internally initiated transaction is using invalid account.`, + data: { + origin, + fromAddress: from, + selectedAddress, + }, + }); } - // Check if the origin has permissions to initiate transactions from the specified address - if (!permittedAddresses.includes(from)) { + if (isExternal && permittedAddresses && !permittedAddresses.includes(from)) { throw providerErrors.unauthorized({ data: { origin } }); } + + if ( + isExternal && + (authorizationList || type === TransactionEnvelopeType.setCode) + ) { + throw rpcErrors.invalidParams( + 'External EIP-7702 transactions are not supported', + ); + } } /** @@ -69,6 +92,7 @@ export function validateTxParams( validateParamData(txParams.data); validateParamChainId(txParams.chainId); validateGasFeeParams(txParams); + validateAuthorizationList(txParams); } /** @@ -308,28 +332,41 @@ function validateGasFeeParams(txParams: TransactionParams) { */ function ensureProperTransactionEnvelopeTypeProvided( txParams: TransactionParams, - field: GasFieldsToValidate, + field: keyof TransactionParams, ) { + const type = txParams.type as TransactionEnvelopeType | undefined; + switch (field) { + case 'authorizationList': + if (type && type !== TransactionEnvelopeType.setCode) { + throw rpcErrors.invalidParams( + `Invalid transaction envelope type: specified type "${type}" but including authorizationList requires type: "${TransactionEnvelopeType.setCode}"`, + ); + } + break; case 'maxFeePerGas': case 'maxPriorityFeePerGas': if ( - txParams.type && - txParams.type !== TransactionEnvelopeType.feeMarket + type && + !TRANSACTION_ENVELOPE_TYPES_FEE_MARKET.includes( + type as TransactionEnvelopeType, + ) ) { throw rpcErrors.invalidParams( - `Invalid transaction envelope type: specified type "${txParams.type}" but including maxFeePerGas and maxPriorityFeePerGas requires type: "${TransactionEnvelopeType.feeMarket}"`, + `Invalid transaction envelope type: specified type "${type}" but including maxFeePerGas and maxPriorityFeePerGas requires type: "${TRANSACTION_ENVELOPE_TYPES_FEE_MARKET.join(', ')}"`, ); } break; case 'gasPrice': default: if ( - txParams.type && - txParams.type === TransactionEnvelopeType.feeMarket + type && + TRANSACTION_ENVELOPE_TYPES_FEE_MARKET.includes( + type as TransactionEnvelopeType, + ) ) { throw rpcErrors.invalidParams( - `Invalid transaction envelope type: specified type "${txParams.type}" but included a gasPrice instead of maxFeePerGas and maxPriorityFeePerGas`, + `Invalid transaction envelope type: specified type "${type}" but included a gasPrice instead of maxFeePerGas and maxPriorityFeePerGas`, ); } } @@ -361,20 +398,79 @@ function ensureMutuallyExclusiveFieldsNotProvided( * Ensures that the provided value for field is a valid hexadecimal. * Throws an invalidParams error if field is not a valid hexadecimal. * - * @param txParams - The transaction parameters object + * @param data - The object containing the field * @param field - The current field being validated * @throws {rpcErrors.invalidParams} Throws if field is not a valid hexadecimal */ -function ensureFieldIsValidHex( - txParams: TransactionParams, - field: GasFieldsToValidate, -) { - const value = txParams[field]; +function ensureFieldIsValidHex(data: T, field: keyof T) { + const value = data[field]; if (typeof value !== 'string' || !isStrictHexString(value)) { throw rpcErrors.invalidParams( - `Invalid transaction params: ${field} is not a valid hexadecimal. got: (${String( + `Invalid transaction params: ${String(field)} is not a valid hexadecimal string. got: (${String( value, )})`, ); } } + +/** + * Validate the authorization list property in the transaction parameters. + * + * @param txParams - The transaction parameters containing the authorization list to validate. + */ +function validateAuthorizationList(txParams: TransactionParams) { + const { authorizationList } = txParams; + + if (!authorizationList) { + return; + } + + ensureProperTransactionEnvelopeTypeProvided(txParams, 'authorizationList'); + + if (!Array.isArray(authorizationList)) { + throw rpcErrors.invalidParams( + `Invalid transaction params: authorizationList must be an array`, + ); + } + + for (const authorization of authorizationList) { + validateAuthorization(authorization); + } +} + +/** + * Validate an authorization object. + * + * @param authorization - The authorization object to validate. + */ +function validateAuthorization(authorization: Authorization) { + ensureFieldIsValidHex(authorization, 'address'); + validateHexLength(authorization.address, 20, 'address'); + + for (const field of ['chainId', 'nonce', 'r', 's', 'yParity'] as const) { + if (authorization[field]) { + ensureFieldIsValidHex(authorization, field); + } + } +} + +/** + * Validate the number of bytes in a hex string. + * + * @param value - The hex string to validate. + * @param lengthBytes - The expected length in bytes. + * @param fieldName - The name of the field being validated. + */ +function validateHexLength( + value: string, + lengthBytes: number, + fieldName: string, +) { + const actualLengthBytes = remove0x(value).length / 2; + + if (actualLengthBytes !== lengthBytes) { + throw rpcErrors.invalidParams( + `Invalid transaction params: ${fieldName} must be ${lengthBytes} bytes. got: ${actualLengthBytes} bytes`, + ); + } +} diff --git a/packages/user-operation-controller/CHANGELOG.md b/packages/user-operation-controller/CHANGELOG.md index 71d2bc56cf3..e8700bbb7ac 100644 --- a/packages/user-operation-controller/CHANGELOG.md +++ b/packages/user-operation-controller/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [25.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency from `^45.0.0` to `^46.0.0` ([#5318](https://github.com/MetaMask/core/pull/5318)) + +## [24.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/polling-controller` from `^12.0.3` to `^12.0.4` ([#5305](https://github.com/MetaMask/core/pull/5305)) + +## [24.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency from `^44.0.0` to `^45.0.0` ([#5292](https://github.com/MetaMask/core/pull/5292)) +- Bump `@metamask/controller-utils` dependency from `^11.4.5` to `^11.5.0`([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + +## [23.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency from `^43.0.0` to `^44.0.0` ([#5218](https://github.com/MetaMask/core/pull/5218)) + ## [22.0.0] ### Changed @@ -314,7 +341,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial Release ([#3749](https://github.com/MetaMask/core/pull/3749)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@22.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@25.0.0...HEAD +[25.0.0]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@24.0.1...@metamask/user-operation-controller@25.0.0 +[24.0.1]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@24.0.0...@metamask/user-operation-controller@24.0.1 +[24.0.0]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@23.0.0...@metamask/user-operation-controller@24.0.0 +[23.0.0]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@22.0.0...@metamask/user-operation-controller@23.0.0 [22.0.0]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@21.0.0...@metamask/user-operation-controller@22.0.0 [21.0.0]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@20.0.1...@metamask/user-operation-controller@21.0.0 [20.0.1]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@20.0.0...@metamask/user-operation-controller@20.0.1 diff --git a/packages/user-operation-controller/package.json b/packages/user-operation-controller/package.json index ec454de9816..8c5fba9fd11 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/user-operation-controller", - "version": "22.0.0", + "version": "25.0.0", "description": "Creates user operations and manages their life cycle", "keywords": [ "MetaMask", @@ -48,26 +48,26 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/base-controller": "^7.1.1", - "@metamask/controller-utils": "^11.4.5", + "@metamask/base-controller": "^8.0.0", + "@metamask/controller-utils": "^11.5.0", "@metamask/eth-query": "^4.0.0", - "@metamask/polling-controller": "^12.0.2", + "@metamask/polling-controller": "^12.0.3", "@metamask/rpc-errors": "^7.0.2", "@metamask/superstruct": "^3.1.0", - "@metamask/utils": "^11.0.1", + "@metamask/utils": "^11.1.0", "bn.js": "^5.2.1", "immer": "^9.0.6", "lodash": "^4.17.21", "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/approval-controller": "^7.1.2", + "@metamask/approval-controller": "^7.1.3", "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-block-tracker": "^11.0.3", - "@metamask/gas-fee-controller": "^22.0.2", - "@metamask/keyring-controller": "^19.0.4", - "@metamask/network-controller": "^22.1.1", - "@metamask/transaction-controller": "^43.0.0", + "@metamask/gas-fee-controller": "^22.0.3", + "@metamask/keyring-controller": "^19.1.0", + "@metamask/network-controller": "^22.2.1", + "@metamask/transaction-controller": "^46.0.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", @@ -82,7 +82,7 @@ "@metamask/gas-fee-controller": "^22.0.0", "@metamask/keyring-controller": "^19.0.0", "@metamask/network-controller": "^22.0.0", - "@metamask/transaction-controller": "^43.0.0" + "@metamask/transaction-controller": "^46.0.0" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/user-operation-controller/src/UserOperationController.ts b/packages/user-operation-controller/src/UserOperationController.ts index eede19f0893..4dca2da946d 100644 --- a/packages/user-operation-controller/src/UserOperationController.ts +++ b/packages/user-operation-controller/src/UserOperationController.ts @@ -3,7 +3,7 @@ import type { AddApprovalRequest, AddResult, } from '@metamask/approval-controller'; -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; +import type { RestrictedMessenger } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import { ApprovalType } from '@metamask/controller-utils'; import EthQuery from '@metamask/eth-query'; @@ -117,7 +117,7 @@ export type UserOperationControllerActions = export type UserOperationControllerEvents = UserOperationStateChange; -export type UserOperationControllerMessenger = RestrictedControllerMessenger< +export type UserOperationControllerMessenger = RestrictedMessenger< typeof controllerName, UserOperationControllerActions, UserOperationControllerEvents, @@ -211,7 +211,7 @@ export class UserOperationController extends BaseController< * @param options - Controller options. * @param options.entrypoint - Address of the entrypoint contract. * @param options.getGasFeeEstimates - Callback to get gas fee estimates. - * @param options.messenger - Restricted controller messenger for the user operation controller. + * @param options.messenger - Restricted messenger for the user operation controller. * @param options.state - Initial state to set on the controller. */ constructor({ diff --git a/scripts/run-eslint.ts b/scripts/run-eslint.ts index 1c70fac5c30..eb4bb7ad546 100644 --- a/scripts/run-eslint.ts +++ b/scripts/run-eslint.ts @@ -1,22 +1,43 @@ +import chalk from 'chalk'; import { ESLint } from 'eslint'; import fs from 'fs'; import path from 'path'; import yargs from 'yargs'; -const EXISTING_WARNINGS_FILE = path.resolve( - __dirname, - '../eslint-warning-thresholds.json', +const PROJECT_DIRECTORY = path.resolve(__dirname, '..'); + +const WARNING_THRESHOLDS_FILE = path.join( + PROJECT_DIRECTORY, + 'eslint-warning-thresholds.json', ); /** - * An object mapping rule IDs to their warning counts. + * A two-level object mapping path to files in which warnings appear to the IDs + * of rules for those warnings, then from rule IDs to the number of warnings for + * the rule. + * + * @example + * ``` ts + * { + * "foo.ts": { + * "rule1": 3, + * "rule2": 4 + * }, + * "bar.ts": { + * "rule3": 17, + * "rule4": 5 + * } + * } + * ``` */ -type WarningCounts = Record; +type WarningCounts = Record>; /** * An object indicating the difference in warnings for a specific rule. */ type WarningComparison = { + /** The file path of the ESLint rule. */ + filePath: string; /** The ID of the ESLint rule. */ ruleId: string; /** The previous count of warnings for the rule. */ @@ -135,125 +156,216 @@ function evaluateWarnings(results: ESLint.LintResult[]) { if (Object.keys(warningThresholds).length === 0) { console.log( - 'The following ESLint warnings were produced and will be captured as thresholds for future runs:\n', + chalk.blue( + 'The following lint violations were produced and will be captured as thresholds for future runs:\n', + ), ); - for (const [ruleId, count] of Object.entries(warningCounts)) { - console.log(`- ${ruleId}: ${count}`); + for (const [filePath, ruleCounts] of Object.entries(warningCounts)) { + console.log(chalk.underline(filePath)); + for (const [ruleId, count] of Object.entries(ruleCounts)) { + console.log(` ${chalk.cyan(ruleId)}: ${count}`); + } } saveWarningThresholds(warningCounts); } else { - const comparisons = compareWarnings(warningThresholds, warningCounts); + const comparisonsByFile = compareWarnings(warningThresholds, warningCounts); - const changes = comparisons.filter( - (comparison) => comparison.difference !== 0, - ); - const regressions = comparisons.filter( - (comparison) => comparison.difference > 0, - ); + const changes = Object.values(comparisonsByFile) + .flat() + .filter((comparison) => comparison.difference !== 0); + const regressions = Object.values(comparisonsByFile) + .flat() + .filter((comparison) => comparison.difference > 0); if (changes.length > 0) { if (regressions.length > 0) { console.log( - '🛑 New ESLint warnings have been introduced and need to be resolved for linting to pass:\n', + chalk.red( + '🛑 New lint violations have been introduced and need to be resolved for linting to pass:\n', + ), ); + + for (const [filePath, fileChanges] of Object.entries( + comparisonsByFile, + )) { + if (fileChanges.some((fileChange) => fileChange.difference > 0)) { + console.log(chalk.underline(filePath)); + for (const { + ruleId, + threshold, + count, + difference, + } of fileChanges) { + if (difference > 0) { + console.log( + ` ${chalk.cyan(ruleId)}: ${threshold} -> ${count} (${difference > 0 ? chalk.red(`+${difference}`) : chalk.green(difference)})`, + ); + } + } + } + } + process.exitCode = 1; } else { console.log( - 'The overall number of ESLint warnings have decreased, good work! ❤️ \n', + chalk.green( + 'The overall number of ESLint warnings has decreased, good work! ❤️ \n', + ), ); - // We are still seeing differences on CI when it comes to linting - // results. Never write the thresholds file in that case. - // eslint-disable-next-line n/no-process-env - if (!process.env.CI) { - saveWarningThresholds(warningCounts); + + for (const [filePath, fileChanges] of Object.entries( + comparisonsByFile, + )) { + if (fileChanges.some((fileChange) => fileChange.difference !== 0)) { + console.log(chalk.underline(filePath)); + for (const { + ruleId, + threshold, + count, + difference, + } of fileChanges) { + if (difference !== 0) { + console.log( + ` ${chalk.cyan(ruleId)}: ${threshold} -> ${count} (${difference > 0 ? chalk.red(`+${difference}`) : chalk.green(difference)})`, + ); + } + } + } } - } - for (const { ruleId, threshold, count, difference } of changes) { console.log( - `- ${ruleId}: ${threshold} -> ${count} (${difference > 0 ? '+' : ''}${difference})`, + `\n${chalk.yellow.bold(path.basename(WARNING_THRESHOLDS_FILE))}${chalk.yellow(' has been updated with the new counts. Please make sure to commit the changes.')}`, ); + + saveWarningThresholds(warningCounts); } } } } /** - * Loads previous warning counts from a file. + * Loads previous warning thresholds from a file. * - * @returns An object mapping rule IDs to their previous warning counts. + * @returns The warning thresholds loaded from file. */ function loadWarningThresholds(): WarningCounts { - if (fs.existsSync(EXISTING_WARNINGS_FILE)) { - const data = fs.readFileSync(EXISTING_WARNINGS_FILE, 'utf-8'); + if (fs.existsSync(WARNING_THRESHOLDS_FILE)) { + const data = fs.readFileSync(WARNING_THRESHOLDS_FILE, 'utf-8'); return JSON.parse(data); } return {}; } /** - * Saves current warning counts to a file so they can be used for a future run. + * Saves current warning counts to a file so they can be referenced in a future + * run. * - * @param warningCounts - An object mapping rule IDs to their current warning - * counts. + * @param newWarningCounts - The new warning thresholds to save. */ -function saveWarningThresholds(warningCounts: WarningCounts): void { +function saveWarningThresholds(newWarningCounts: WarningCounts): void { fs.writeFileSync( - EXISTING_WARNINGS_FILE, - `${JSON.stringify(warningCounts, null, 2)}\n`, + WARNING_THRESHOLDS_FILE, + `${JSON.stringify(newWarningCounts, null, 2)}\n`, 'utf-8', ); } /** * Given a list of results from an the ESLint run, counts the number of warnings - * produced per rule. + * produced per file and rule. * * @param results - The ESLint results. - * @returns An object mapping rule IDs to their warning counts, sorted by rule - * ID. + * @returns A two-level object mapping path to files in which warnings appear to + * the IDs of rules for those warnings, then from rule IDs to the number of + * warnings for the rule. */ function getWarningCounts(results: ESLint.LintResult[]): WarningCounts { - const warningCounts = results.reduce((acc, result) => { - for (const message of result.messages) { - if (message.severity === WARNING && message.ruleId) { - acc[message.ruleId] = (acc[message.ruleId] ?? 0) + 1; + const unsortedWarningCounts = results.reduce( + (workingWarningCounts, result) => { + const { filePath } = result; + const relativeFilePath = path.relative(PROJECT_DIRECTORY, filePath); + for (const message of result.messages) { + if (message.severity === WARNING && message.ruleId) { + if (!workingWarningCounts[relativeFilePath]) { + workingWarningCounts[relativeFilePath] = {}; + } + workingWarningCounts[relativeFilePath][message.ruleId] = + (workingWarningCounts[relativeFilePath][message.ruleId] ?? 0) + 1; + } } - } - return acc; - }, {} as WarningCounts); - - return Object.keys(warningCounts) - .sort(sortRules) - .reduce((sortedWarningCounts, key) => { - return { ...sortedWarningCounts, [key]: warningCounts[key] }; - }, {} as WarningCounts); + return workingWarningCounts; + }, + {} as WarningCounts, + ); + + const sortedWarningCounts: WarningCounts = {}; + for (const filePath of Object.keys(unsortedWarningCounts).sort()) { + // We can safely assume this property is present. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const unsortedWarningCountsForFile = unsortedWarningCounts[filePath]!; + sortedWarningCounts[filePath] = Object.keys(unsortedWarningCountsForFile) + .sort(sortRules) + .reduce( + (acc, ruleId) => { + // We can safely assume this property is present. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + acc[ruleId] = unsortedWarningCountsForFile[ruleId]!; + return acc; + }, + {} as Record, + ); + } + return sortedWarningCounts; } /** * Compares previous and current warning counts. * - * @param warningThresholds - An object mapping rule IDs to the warning - * thresholds established in a previous run. - * @param warningCounts - An object mapping rule IDs to the current warning - * counts. - * @returns An array of objects indicating comparisons in warnings. + * @param warningThresholds - The previously recorded warning thresholds + * (organized by file and then rule). + * @param warningCounts - The current warning counts (organized by file and then + * rule). + * @returns An object mapping file paths to arrays of objects indicating + * comparisons in warnings. */ function compareWarnings( warningThresholds: WarningCounts, warningCounts: WarningCounts, -): WarningComparison[] { - const ruleIds = Array.from( +): Record { + const comparisons: Record = {}; + const filePaths = Array.from( new Set([...Object.keys(warningThresholds), ...Object.keys(warningCounts)]), ); - return ruleIds - .map((ruleId) => { - const threshold = warningThresholds[ruleId] ?? 0; - const count = warningCounts[ruleId] ?? 0; - const difference = count - threshold; - return { ruleId, threshold, count, difference }; - }) - .sort((a, b) => sortRules(a.ruleId, b.ruleId)); + + for (const filePath of filePaths) { + const ruleIds = Array.from( + new Set([ + ...Object.keys(warningThresholds[filePath] || {}), + ...Object.keys(warningCounts[filePath] || {}), + ]), + ); + + comparisons[filePath] = ruleIds + .map((ruleId) => { + const threshold = warningThresholds[filePath]?.[ruleId] ?? 0; + const count = warningCounts[filePath]?.[ruleId] ?? 0; + const difference = count - threshold; + return { filePath, ruleId, threshold, count, difference }; + }) + .sort((a, b) => sortRules(a.ruleId, b.ruleId)); + } + + return Object.keys(comparisons) + .sort() + .reduce( + (sortedComparisons, filePath) => { + // We can safely assume this property is present. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sortedComparisons[filePath] = comparisons[filePath]!; + return sortedComparisons; + }, + {} as Record, + ); } /** diff --git a/teams.json b/teams.json index 8041bd3ee28..427e514be97 100644 --- a/teams.json +++ b/teams.json @@ -5,6 +5,7 @@ "metamask/approval-controller": "team-confirmations", "metamask/assets-controllers": "team-assets", "metamask/base-controller": "team-wallet-framework", + "metamask/bridge-controller": "team-swaps,team-bridge", "metamask/build-utils": "team-wallet-framework", "metamask/composable-controller": "team-wallet-framework", "metamask/controller-utils": "team-wallet-framework", @@ -17,6 +18,7 @@ "metamask/logging-controller": "team-confirmations", "metamask/message-manager": "team-confirmations", "metamask/multichain": "team-wallet-api-platform", + "metamask/multichain-network-controller": "team-wallet-api-platform", "metamask/name-controller": "team-confirmations", "metamask/network-controller": "team-wallet-framework,team-assets", "metamask/notification-controller": "team-snaps-platform", @@ -35,5 +37,6 @@ "metamask/transaction-controller": "team-confirmations", "metamask/user-operation-controller": "team-confirmations", "metamask/multichain-transactions-controller": "team-sol,team-accounts", - "metamask/token-search-discovery-controller": "team-portfolio" + "metamask/token-search-discovery-controller": "team-portfolio", + "metamask/earn-controller": "team-earn" } diff --git a/tsconfig.build.json b/tsconfig.build.json index 6017c40c24b..a091abb09e7 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -9,6 +9,7 @@ { "path": "./packages/build-utils/tsconfig.build.json" }, { "path": "./packages/composable-controller/tsconfig.build.json" }, { "path": "./packages/controller-utils/tsconfig.build.json" }, + { "path": "./packages/earn-controller/tsconfig.build.json" }, { "path": "./packages/ens-controller/tsconfig.build.json" }, { "path": "./packages/eth-json-rpc-provider/tsconfig.build.json" }, { "path": "./packages/gas-fee-controller/tsconfig.build.json" }, @@ -41,7 +42,8 @@ "path": "./packages/token-search-discovery-controller/tsconfig.build.json" }, { "path": "./packages/transaction-controller/tsconfig.build.json" }, - { "path": "./packages/user-operation-controller/tsconfig.build.json" } + { "path": "./packages/user-operation-controller/tsconfig.build.json" }, + { "path": "./packages/multichain-network-controller/tsconfig.build.json" } ], "files": [], "include": [] diff --git a/tsconfig.json b/tsconfig.json index f77d0f4f623..489ba07d2a9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ { "path": "./packages/build-utils" }, { "path": "./packages/composable-controller" }, { "path": "./packages/controller-utils" }, + { "path": "./packages/earn-controller" }, { "path": "./packages/ens-controller" }, { "path": "./packages/eth-json-rpc-provider" }, { "path": "./packages/gas-fee-controller" }, @@ -25,6 +26,7 @@ { "path": "./packages/message-manager" }, { "path": "./packages/multichain" }, { "path": "./packages/multichain-transactions-controller" }, + { "path": "./packages/multichain-network-controller" }, { "path": "./packages/name-controller" }, { "path": "./packages/network-controller" }, { "path": "./packages/notification-services-controller" }, diff --git a/yarn.config.cjs b/yarn.config.cjs index aa8f334e172..9d6fe4526f7 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -24,6 +24,9 @@ const { inspect } = require('util'); */ const ALLOWED_INCONSISTENT_DEPENDENCIES = { // '@metamask/json-rpc-engine': ['^9.0.3'], + // Temporary to allow separate keyring API and keyring-controller upgrade. + '@ethereumjs/common': ['^3.2.0'], + '@ethereumjs/tx': ['^4.2.0'], }; /** diff --git a/yarn.lock b/yarn.lock index c48d3f85a7d..d934e6a8810 100644 --- a/yarn.lock +++ b/yarn.lock @@ -823,6 +823,15 @@ __metadata: languageName: node linkType: hard +"@ethereumjs/common@npm:^4.4.0": + version: 4.4.0 + resolution: "@ethereumjs/common@npm:4.4.0" + dependencies: + "@ethereumjs/util": "npm:^9.1.0" + checksum: 10/dd5cc78575a762b367601f94d6af7e36cb3a5ecab45eec0c1259c433e755a16c867753aa88f331e3963791a18424ad0549682a3a6a0a160640fe846db6ce8014 + languageName: node + linkType: hard + "@ethereumjs/rlp@npm:^4.0.1": version: 4.0.1 resolution: "@ethereumjs/rlp@npm:4.0.1" @@ -832,6 +841,15 @@ __metadata: languageName: node linkType: hard +"@ethereumjs/rlp@npm:^5.0.2": + version: 5.0.2 + resolution: "@ethereumjs/rlp@npm:5.0.2" + bin: + rlp: bin/rlp.cjs + checksum: 10/2af80d98faf7f64dfb6d739c2df7da7350ff5ad52426c3219897e843ee441215db0ffa346873200a6be6d11142edb9536e66acd62436b5005fa935baaf7eb6bd + languageName: node + linkType: hard + "@ethereumjs/tx@npm:^4.0.2, @ethereumjs/tx@npm:^4.2.0": version: 4.2.0 resolution: "@ethereumjs/tx@npm:4.2.0" @@ -844,6 +862,18 @@ __metadata: languageName: node linkType: hard +"@ethereumjs/tx@npm:^5.4.0": + version: 5.4.0 + resolution: "@ethereumjs/tx@npm:5.4.0" + dependencies: + "@ethereumjs/common": "npm:^4.4.0" + "@ethereumjs/rlp": "npm:^5.0.2" + "@ethereumjs/util": "npm:^9.1.0" + ethereum-cryptography: "npm:^2.2.1" + checksum: 10/8d2c0a69ab37015f945f9de065cfb9f05e8e79179efeed725ea0a14760c3eb8ff900bcf915bb71ec29fe2f753db35d1b78a15ac4ddec489e87c995dec1ba6e85 + languageName: node + linkType: hard + "@ethereumjs/util@npm:^8.0.0, @ethereumjs/util@npm:^8.1.0": version: 8.1.0 resolution: "@ethereumjs/util@npm:8.1.0" @@ -855,6 +885,16 @@ __metadata: languageName: node linkType: hard +"@ethereumjs/util@npm:^9.1.0": + version: 9.1.0 + resolution: "@ethereumjs/util@npm:9.1.0" + dependencies: + "@ethereumjs/rlp": "npm:^5.0.2" + ethereum-cryptography: "npm:^2.2.1" + checksum: 10/4e22c4081c63eebb808eccd54f7f91cd3407f4cac192da5f30a0d6983fe07d51f25e6a9d08624f1376e604bb7dce574aafcf0fbf0becf42f62687c11e710ac41 + languageName: node + linkType: hard + "@ethersproject/abi@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/abi@npm:5.7.0" @@ -1145,538 +1185,541 @@ __metadata: languageName: node linkType: hard -"@fastify/busboy@npm:^2.0.0": - version: 2.1.1 - resolution: "@fastify/busboy@npm:2.1.1" - checksum: 10/2bb8a7eca8289ed14c9eb15239bc1019797454624e769b39a0b90ed204d032403adc0f8ed0d2aef8a18c772205fa7808cf5a1b91f21c7bfc7b6032150b1062c5 - languageName: node - linkType: hard - -"@firebase/analytics-compat@npm:0.2.13": - version: 0.2.13 - resolution: "@firebase/analytics-compat@npm:0.2.13" +"@firebase/analytics-compat@npm:0.2.17": + version: 0.2.17 + resolution: "@firebase/analytics-compat@npm:0.2.17" dependencies: - "@firebase/analytics": "npm:0.10.7" - "@firebase/analytics-types": "npm:0.8.2" - "@firebase/component": "npm:0.6.8" - "@firebase/util": "npm:1.9.7" + "@firebase/analytics": "npm:0.10.11" + "@firebase/analytics-types": "npm:0.8.3" + "@firebase/component": "npm:0.6.12" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app-compat": 0.x - checksum: 10/37af40ac540c68593a118e75c0fc2031459633b46d3fd32f97290fd8c3865fe425cc85feac2243c461924da698e27f4a5c174898e847027b5afd347274080c9c + checksum: 10/3b048b41e0405a3975050f5d55afa923263ba3768d7b1b635d70892504cac03bd0bcf353b44819959dc6de7c04f1df818e34cec705c8ce18cf5c0866abe277b9 languageName: node linkType: hard -"@firebase/analytics-types@npm:0.8.2": - version: 0.8.2 - resolution: "@firebase/analytics-types@npm:0.8.2" - checksum: 10/297fb7becbc51950c7de5809fed896c391d1e87b4d8bb4bf88f4e8760b2e32f903a7dd8e92de4424b49c4e2ecb60a44d49e2f9c68ac3f7ffe3a0194f78910392 +"@firebase/analytics-types@npm:0.8.3": + version: 0.8.3 + resolution: "@firebase/analytics-types@npm:0.8.3" + checksum: 10/8292a400af00b08d201dd833095e041602c460d6fb3da54251a1a8811da1416fd82a8b8bd162574fe75decf233a4a07367b4d794d1d85cde91c7ae52747b1b20 languageName: node linkType: hard -"@firebase/analytics@npm:0.10.7": - version: 0.10.7 - resolution: "@firebase/analytics@npm:0.10.7" +"@firebase/analytics@npm:0.10.11": + version: 0.10.11 + resolution: "@firebase/analytics@npm:0.10.11" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/installations": "npm:0.6.8" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.9.7" + "@firebase/component": "npm:0.6.12" + "@firebase/installations": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app": 0.x - checksum: 10/610c67d4ebbd671ace4d0baf2fa7733e1fa97a2f900cb6337b054528a7c98110d861030b2f3c95553ed81253a9cd152edf2b9dc9388a8bec679050bf46674cfe + checksum: 10/804083f61ffc57dabeb7a1b49e16f86969d1b2a37fafc23633c90324768ab849e52324b6a10928d789e038ec2f5d93248717f18d5f0d2a4b916850b86051c214 languageName: node linkType: hard -"@firebase/app-check-compat@npm:0.3.14": - version: 0.3.14 - resolution: "@firebase/app-check-compat@npm:0.3.14" +"@firebase/app-check-compat@npm:0.3.18": + version: 0.3.18 + resolution: "@firebase/app-check-compat@npm:0.3.18" dependencies: - "@firebase/app-check": "npm:0.8.7" - "@firebase/app-check-types": "npm:0.5.2" - "@firebase/component": "npm:0.6.8" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.9.7" + "@firebase/app-check": "npm:0.8.11" + "@firebase/app-check-types": "npm:0.5.3" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app-compat": 0.x - checksum: 10/8d4c835fa95474d556b36ec73f571f3e967cbe590cc76f9964eb9d50905becadf195c5e3cd628fc278efb162529a86a0b5433b72d5d232249168865ab8564392 + checksum: 10/24b103fc309fa66d9830614c69bdf62810ecf0b77ad4fc9f318e05361a686cc3a684d84bddbd6afddc6a641739ead93ab1e8c28a75ed915750602b371aeb9b32 languageName: node linkType: hard -"@firebase/app-check-interop-types@npm:0.3.2": - version: 0.3.2 - resolution: "@firebase/app-check-interop-types@npm:0.3.2" - checksum: 10/3effe656a4762c541838f4bde91b4498e51d48389046b930dc3dbb012e54b6ab0727f7c68a3e94198f633d57833346fc337a0847b6b03d2407030e1489d466fe +"@firebase/app-check-interop-types@npm:0.3.3": + version: 0.3.3 + resolution: "@firebase/app-check-interop-types@npm:0.3.3" + checksum: 10/55d92d9907fa137ae0e71ff14ad3be2d11c86d0e04bed7e8e58ba8f08531ce4867fa6fc75d9f8da86c0f8d05df15f34b13fe40014c3210e98ac00d2d9a0d4faa languageName: node linkType: hard -"@firebase/app-check-types@npm:0.5.2": - version: 0.5.2 - resolution: "@firebase/app-check-types@npm:0.5.2" - checksum: 10/2b33a7adfb7b6ebf5423940bf0af5909df69bf2d6184e12e989f6c76062077be16c31193795349862b4f8aab6b3059806b732a92995cae30fd77419f19a86c6e +"@firebase/app-check-types@npm:0.5.3": + version: 0.5.3 + resolution: "@firebase/app-check-types@npm:0.5.3" + checksum: 10/8ffdd1a678060abe10daa9b7fbf2e0d30585b5e7b066adbcaf6aa89daee94d02683d3b41225fde7dd8b0d7cc8c3ac1d9053685099167aff5d407427dfbaeebcf languageName: node linkType: hard -"@firebase/app-check@npm:0.8.7": - version: 0.8.7 - resolution: "@firebase/app-check@npm:0.8.7" +"@firebase/app-check@npm:0.8.11": + version: 0.8.11 + resolution: "@firebase/app-check@npm:0.8.11" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.9.7" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app": 0.x - checksum: 10/3f09160bccd99006e33d7bc342dc6c242094be7ffccc6ff0259207006f93f645badb5c1c1743c8a1540d0dab6536a5019eff0ba231b37488ead1209b853287ac + checksum: 10/e3f6a3940037c17a2faaf97a700d33b2c7821e07460e0a854d9f542acdcb589514bb4699df3adba1fb1d17ee75261006939b8ef60ec44bbe6c8c827b0797aa77 languageName: node linkType: hard -"@firebase/app-compat@npm:0.2.39": - version: 0.2.39 - resolution: "@firebase/app-compat@npm:0.2.39" +"@firebase/app-compat@npm:0.2.48": + version: 0.2.48 + resolution: "@firebase/app-compat@npm:0.2.48" dependencies: - "@firebase/app": "npm:0.10.9" - "@firebase/component": "npm:0.6.8" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.9.7" + "@firebase/app": "npm:0.10.18" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" - checksum: 10/266a35f349417d8f5ecc69b4d877b0a41e4ef78ddd927fa8f90eb15ea3d709bed7d504b6be49407e2a1e14ad392933be6aff5eb67c2127ab68a00ddb7c26f806 + checksum: 10/b74598b960ebb0a773b04e04d45dd59dbc8e09d1ae46c8ee7fd950632c95d357e8edab353df7032b798f2613884c96f3201eb5fbcdbfba67cb23757d66e63586 languageName: node linkType: hard -"@firebase/app-types@npm:0.9.2": - version: 0.9.2 - resolution: "@firebase/app-types@npm:0.9.2" - checksum: 10/566b3714a4d7e8180514258e4b1549bf5b28ae0383b4ff53d3532a45e114048afdd27c1fef8688d871dd9e5ad5307e749776e23f094122655ac6b0fb550eb11a +"@firebase/app-types@npm:0.9.3": + version: 0.9.3 + resolution: "@firebase/app-types@npm:0.9.3" + checksum: 10/a980165e1433f0c4bb269be1f5cf25bf1d048a0e9f161779a71eb028def9bdcea82852cecee19baecee4fa602e5e62414120aabdf2b9722b8349c877f222b85a languageName: node linkType: hard -"@firebase/app@npm:0.10.9": - version: 0.10.9 - resolution: "@firebase/app@npm:0.10.9" +"@firebase/app@npm:0.10.18": + version: 0.10.18 + resolution: "@firebase/app@npm:0.10.18" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.9.7" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" idb: "npm:7.1.1" tslib: "npm:^2.1.0" - checksum: 10/faf344390ce2a857171ca347ca6b81b867b6d4acd87232698c4a93678100c82308796cf906cecad287dadcc83b0a645a22678292b3fb3a97c94cc3ee44c88609 + checksum: 10/ac215e594d66e933207263c4c11ff585ba3843a0e73ab6f02c85f504f7b5e166f407a9bef299f5a91893840c7f5c8978895c0f6103b361fb188c1cfdb8c35030 languageName: node linkType: hard -"@firebase/auth-compat@npm:0.5.12": - version: 0.5.12 - resolution: "@firebase/auth-compat@npm:0.5.12" +"@firebase/auth-compat@npm:0.5.17": + version: 0.5.17 + resolution: "@firebase/auth-compat@npm:0.5.17" dependencies: - "@firebase/auth": "npm:1.7.7" - "@firebase/auth-types": "npm:0.12.2" - "@firebase/component": "npm:0.6.8" - "@firebase/util": "npm:1.9.7" + "@firebase/auth": "npm:1.8.2" + "@firebase/auth-types": "npm:0.12.3" + "@firebase/component": "npm:0.6.12" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" - undici: "npm:5.28.4" peerDependencies: "@firebase/app-compat": 0.x - checksum: 10/1eeeec3ce7983dbadd1e95cf75e7665486d68eb60b0bc56642412120bdb05d2a389294343532b3cff2114ab8b782c00620ddecf5c194944bfcaead016075a568 + checksum: 10/4c6d0fa6f76c398872627f49c427c810269c0284bdca1acddf82b154c9cda7131e8acecd961c2e0947f0340428b67349b7f9471bb1bd75bd82839ce89879ccad languageName: node linkType: hard -"@firebase/auth-interop-types@npm:0.2.3": - version: 0.2.3 - resolution: "@firebase/auth-interop-types@npm:0.2.3" - checksum: 10/e55b8ded6bd1a5e6a2845c9c7ed520bb9a8a76e4ddf90249bf685986ac7b1fb079be2fa4edcb6a3aa81d1d56870a470eadcd5a8f20b797dccd803d72ed4c80aa +"@firebase/auth-interop-types@npm:0.2.4": + version: 0.2.4 + resolution: "@firebase/auth-interop-types@npm:0.2.4" + checksum: 10/a76abd5037e6e45e79f90fce4e3741142c12b24963aabb07a5098690ef4da2a6073e6a81437d926b1a27716f4f9edc56b7296f7160cb6cc48464969cb77197bc languageName: node linkType: hard -"@firebase/auth-types@npm:0.12.2": - version: 0.12.2 - resolution: "@firebase/auth-types@npm:0.12.2" +"@firebase/auth-types@npm:0.12.3": + version: 0.12.3 + resolution: "@firebase/auth-types@npm:0.12.3" peerDependencies: "@firebase/app-types": 0.x "@firebase/util": 1.x - checksum: 10/f55449381de8e2a24ffaf19f12b5c4a093c8323034253ea7a5f7afc946327d20b09f32a483c12960862a1c4814645ea80bc4343f0a9f22db5dc048ca82773132 + checksum: 10/5eda88380e9b33a6c91b0f8dd6a581895c2770aa5b46b1928a006a74d35c6a310bfe737141ff013764a4e02815efa530f1576d674f09f905fbe3b14050dc7fce languageName: node linkType: hard -"@firebase/auth@npm:1.7.7": - version: 1.7.7 - resolution: "@firebase/auth@npm:1.7.7" +"@firebase/auth@npm:1.8.2": + version: 1.8.2 + resolution: "@firebase/auth@npm:1.8.2" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.9.7" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" - undici: "npm:5.28.4" peerDependencies: "@firebase/app": 0.x "@react-native-async-storage/async-storage": ^1.18.1 peerDependenciesMeta: "@react-native-async-storage/async-storage": optional: true - checksum: 10/fe329cdb9cd827cf0c3dd6cba6090496b79bcc09b0e53e685026dc9a1c723310387d4e3655d300ed4789608c7d0fcacbb666c19c4c2be7f416dcc47fb91df0cd + checksum: 10/8cfe5e6d78ea555f52bffad6e4b21824a30040fd52ffeb3d60edf0c122f0cbb66fc012e708f49473f045fa3064a4ac760e8bc6b24d5ccdf4ae7087b07da61247 languageName: node linkType: hard -"@firebase/component@npm:0.6.8": - version: 0.6.8 - resolution: "@firebase/component@npm:0.6.8" +"@firebase/component@npm:0.6.12": + version: 0.6.12 + resolution: "@firebase/component@npm:0.6.12" dependencies: - "@firebase/util": "npm:1.9.7" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" - checksum: 10/0df2a61a9d3a32981a82889b4f23923c9adc468e89cadec5984b52d2422bb2b184c1219ed78dc7ec0b7f973ac0b7c2e8f486dee4a32a6741c0627648960e4314 + checksum: 10/4dfd201d3709ef5eed477e13d399611a78a186ca8911846e24361f9848c3b4eecc14c295a8f78ec40c88816329fde0ba6cc30dce9a444fa43a619b7ea744f0dc languageName: node linkType: hard -"@firebase/database-compat@npm:1.0.7": - version: 1.0.7 - resolution: "@firebase/database-compat@npm:1.0.7" +"@firebase/data-connect@npm:0.2.0": + version: 0.2.0 + resolution: "@firebase/data-connect@npm:0.2.0" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/database": "npm:1.0.7" - "@firebase/database-types": "npm:1.0.4" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.9.7" + "@firebase/auth-interop-types": "npm:0.2.4" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" - checksum: 10/87d6185b65d58784e0c645a8be232f034d7e3bdfbe030b7d88b5597f7d18dc9329a6c6b0eab84f887ab87ef34caf171afc5b97bbd917fa8682015286cb9fcad4 + peerDependencies: + "@firebase/app": 0.x + checksum: 10/7ba5886bc69b0a42757539a3de417d550ca3359f495a3d8a3974e799a21fbcc2ea15393c00e183dcd01a845d42cad15a914345b4bed63bd401089861e92b1b35 languageName: node linkType: hard -"@firebase/database-types@npm:1.0.4": - version: 1.0.4 - resolution: "@firebase/database-types@npm:1.0.4" +"@firebase/database-compat@npm:2.0.2": + version: 2.0.2 + resolution: "@firebase/database-compat@npm:2.0.2" dependencies: - "@firebase/app-types": "npm:0.9.2" - "@firebase/util": "npm:1.9.7" - checksum: 10/d76125998d322d1fa31a6bf028e21ba03eafb26d7ae3b408ea8f84f52caf1dea716a236a21c64deb857c5eb091ea53cf148b9a2b99f4e97efc5b7c8cabae9acd + "@firebase/component": "npm:0.6.12" + "@firebase/database": "npm:1.0.11" + "@firebase/database-types": "npm:1.0.8" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" + tslib: "npm:^2.1.0" + checksum: 10/5a341662c32f08f248ce9e8cecb940169f618c42a5a85de72247f13ffa32d5ca0a5619d0330f6ff8c7e1ea6952733534531e03e53e2746732bcfc6e851c031b3 languageName: node linkType: hard -"@firebase/database@npm:1.0.7": - version: 1.0.7 - resolution: "@firebase/database@npm:1.0.7" +"@firebase/database-types@npm:1.0.8": + version: 1.0.8 + resolution: "@firebase/database-types@npm:1.0.8" + dependencies: + "@firebase/app-types": "npm:0.9.3" + "@firebase/util": "npm:1.10.3" + checksum: 10/1b5483de082ff8d7551b21f087ba2f237bcd38ca9e3f48b1377b96213718e0a206437fe31a4e055c1b90d05a1f38f89fe1c92d50d907ca06c8727c73fc521c40 + languageName: node + linkType: hard + +"@firebase/database@npm:1.0.11": + version: 1.0.11 + resolution: "@firebase/database@npm:1.0.11" dependencies: - "@firebase/app-check-interop-types": "npm:0.3.2" - "@firebase/auth-interop-types": "npm:0.2.3" - "@firebase/component": "npm:0.6.8" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.9.7" + "@firebase/app-check-interop-types": "npm:0.3.3" + "@firebase/auth-interop-types": "npm:0.2.4" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" faye-websocket: "npm:0.11.4" tslib: "npm:^2.1.0" - checksum: 10/d299c07ac647efb09b644ff91c2aa1d49622c16adc40f75506563aeb8de73f79b17949a13db8089337497e570cebf0df69e8404e934d44a49bb703d05375c245 + checksum: 10/8df5c54a6e88ecd2f71fe5bf156d23132c92f698210e23f27144dd871ea518e2268dc0eac91152091c8b75dbdf66d18c0ca623e80d1d3a69af5a3ed956a26e59 languageName: node linkType: hard -"@firebase/firestore-compat@npm:0.3.35": - version: 0.3.35 - resolution: "@firebase/firestore-compat@npm:0.3.35" +"@firebase/firestore-compat@npm:0.3.41": + version: 0.3.41 + resolution: "@firebase/firestore-compat@npm:0.3.41" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/firestore": "npm:4.7.0" - "@firebase/firestore-types": "npm:3.0.2" - "@firebase/util": "npm:1.9.7" + "@firebase/component": "npm:0.6.12" + "@firebase/firestore": "npm:4.7.6" + "@firebase/firestore-types": "npm:3.0.3" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app-compat": 0.x - checksum: 10/e3d4cb5cbf555e840eef862cace318d5dc829546c2c7e29d8cabd5ff5d0eb7c756405c4985490c0e1cd524b3dd1ed4e19d4d4a06230e5b6126adac0571010d99 + checksum: 10/a719fc6bd1150b5b1653053e73709b2b2edddb6c2a9274a896f9b38a6a09e92d650dbb5df55aceaf23c413445a8beb18073b8726247df9aadbe13d175154fff1 languageName: node linkType: hard -"@firebase/firestore-types@npm:3.0.2": - version: 3.0.2 - resolution: "@firebase/firestore-types@npm:3.0.2" +"@firebase/firestore-types@npm:3.0.3": + version: 3.0.3 + resolution: "@firebase/firestore-types@npm:3.0.3" peerDependencies: "@firebase/app-types": 0.x "@firebase/util": 1.x - checksum: 10/81e91f836a026ecb70937407ca8699add7abb5b050d8815620cde97c3eec3f78f7dfbb366225758909f0df31d9f21e98a84ba62701bd27ee38b2609898c11acd + checksum: 10/98b5153b3b98d5a1aa67385962619966352752e49d1120425e608bb4b715d60674943808d9bdb7587a8e7ab2e821fc2d470974d7e0d7419cb333e846c1ab038c languageName: node linkType: hard -"@firebase/firestore@npm:4.7.0": - version: 4.7.0 - resolution: "@firebase/firestore@npm:4.7.0" +"@firebase/firestore@npm:4.7.6": + version: 4.7.6 + resolution: "@firebase/firestore@npm:4.7.6" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.9.7" - "@firebase/webchannel-wrapper": "npm:1.0.1" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" + "@firebase/webchannel-wrapper": "npm:1.0.3" "@grpc/grpc-js": "npm:~1.9.0" "@grpc/proto-loader": "npm:^0.7.8" tslib: "npm:^2.1.0" - undici: "npm:5.28.4" peerDependencies: "@firebase/app": 0.x - checksum: 10/b3cb3a62bd3cc5b7ade8689d396d0b0a49821d8709caf787078bbc9306e5d4ddbcb20afbda5138b09934e5fc30ce3561e53419776e7ca454a5025fd195343b12 + checksum: 10/76e879675b34212af74e3d294458e254c3f547d4168a377074671317b3bcfc07acdff1e853bd1f139b8e4a767e91749f00ee00aa52d968c67f190fe490256151 languageName: node linkType: hard -"@firebase/functions-compat@npm:0.3.12": - version: 0.3.12 - resolution: "@firebase/functions-compat@npm:0.3.12" +"@firebase/functions-compat@npm:0.3.18": + version: 0.3.18 + resolution: "@firebase/functions-compat@npm:0.3.18" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/functions": "npm:0.11.6" - "@firebase/functions-types": "npm:0.6.2" - "@firebase/util": "npm:1.9.7" + "@firebase/component": "npm:0.6.12" + "@firebase/functions": "npm:0.12.1" + "@firebase/functions-types": "npm:0.6.3" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app-compat": 0.x - checksum: 10/d9803c909e848dc381c892d36885a9519b5b025a46afb2c096bf099e840c5c0afc2290b5660a9d763cf6a8bf0ad6f303f85d3011abfc02d75348452bfe3200c8 + checksum: 10/224132bbd592c73c717fb8b065a8e718d43c1f72d05135313fd19f7efd566164217d13e65bf6f142973bc35b29ff792c414610a9ddcd708601ddf718d739d3c9 languageName: node linkType: hard -"@firebase/functions-types@npm:0.6.2": - version: 0.6.2 - resolution: "@firebase/functions-types@npm:0.6.2" - checksum: 10/5b8733f9d4bd85a617d35dd10ce296d9ec0490494e584697c4eda8098ff1e865607d7880b84194e86c35d438bbcd714977c111180502d0d1b6b2da1cde1b37ca +"@firebase/functions-types@npm:0.6.3": + version: 0.6.3 + resolution: "@firebase/functions-types@npm:0.6.3" + checksum: 10/95fc99d7c1420f119136d1e048c9bf32e5bf644453c8c3a406e0fd11506f2191f9b4b1df087e6e978daeb7d1b52a98bb8de9f9acec8a1934f925e9004a0ade47 languageName: node linkType: hard -"@firebase/functions@npm:0.11.6": - version: 0.11.6 - resolution: "@firebase/functions@npm:0.11.6" +"@firebase/functions@npm:0.12.1": + version: 0.12.1 + resolution: "@firebase/functions@npm:0.12.1" dependencies: - "@firebase/app-check-interop-types": "npm:0.3.2" - "@firebase/auth-interop-types": "npm:0.2.3" - "@firebase/component": "npm:0.6.8" - "@firebase/messaging-interop-types": "npm:0.2.2" - "@firebase/util": "npm:1.9.7" + "@firebase/app-check-interop-types": "npm:0.3.3" + "@firebase/auth-interop-types": "npm:0.2.4" + "@firebase/component": "npm:0.6.12" + "@firebase/messaging-interop-types": "npm:0.2.3" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" - undici: "npm:5.28.4" peerDependencies: "@firebase/app": 0.x - checksum: 10/c1ac2887dd986c8abc408db1da26531f5f9d252f2cd4ee36352239ed0a0fc11902dd1f85db9ec21818028fd737c5a09461c01c0701c85f9ea96b9dc8dfc69f03 + checksum: 10/db32ed6297a1f187062c772f3134f19849e3f1e55345838ebf2256555f1d65648c018ead208909bafd9620deba1191385f4223835cdad5c1c4e9567cb9244721 languageName: node linkType: hard -"@firebase/installations-compat@npm:0.2.8": - version: 0.2.8 - resolution: "@firebase/installations-compat@npm:0.2.8" +"@firebase/installations-compat@npm:0.2.12": + version: 0.2.12 + resolution: "@firebase/installations-compat@npm:0.2.12" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/installations": "npm:0.6.8" - "@firebase/installations-types": "npm:0.5.2" - "@firebase/util": "npm:1.9.7" + "@firebase/component": "npm:0.6.12" + "@firebase/installations": "npm:0.6.12" + "@firebase/installations-types": "npm:0.5.3" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app-compat": 0.x - checksum: 10/b0eece054763ac6d229b2fca7ead9cbdd10e6c8429be9f03d95e8ed4a488fcf1cbc3a136f49800b07f2b82fb0f5ff1fecb41bee923aa045314ed854bada256d8 + checksum: 10/ffd5e08e65e7067c06a4eb5601a09b017fce006b38108c10c412df8144e79bd08b4347998740425f312288b5a0839818e634486875857df5518c05a737c46ad8 languageName: node linkType: hard -"@firebase/installations-types@npm:0.5.2": - version: 0.5.2 - resolution: "@firebase/installations-types@npm:0.5.2" +"@firebase/installations-types@npm:0.5.3": + version: 0.5.3 + resolution: "@firebase/installations-types@npm:0.5.3" peerDependencies: "@firebase/app-types": 0.x - checksum: 10/2e795280c299d644b8c8e3fdfa5c6f20cb367dd3b7df32317211f84393fa169b33dee0cbed28de407f3b22dc8f1fb2f7a11ae5a373f8082cc570ef61ef6b91ba + checksum: 10/7f3fbdc028bda9124b6d46609be5bf6dfd18e76b62da6a5a1bc233e750f0aa81a996b010049083c475abeec6b304d0b0b9a6d87e713f0b3c7db8c7c702c16d05 languageName: node linkType: hard -"@firebase/installations@npm:0.6.8": - version: 0.6.8 - resolution: "@firebase/installations@npm:0.6.8" +"@firebase/installations@npm:0.6.12": + version: 0.6.12 + resolution: "@firebase/installations@npm:0.6.12" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/util": "npm:1.9.7" + "@firebase/component": "npm:0.6.12" + "@firebase/util": "npm:1.10.3" idb: "npm:7.1.1" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app": 0.x - checksum: 10/84cdf30d393fad859f035b276c0ef372cd94907fe042498cdd7c24a719d786866a2f976834b90a49c543e37ae317edd669676830ba7cd83f3a3d34b6f2c0f1ee + checksum: 10/093295de087b4c9287d06243eb19814e25674047aafa4f5db9a222d8e64283d0362f37edf8cfbe882a80eac1d2d9fc52b821fbb01151ac925f023765251dd1de languageName: node linkType: hard -"@firebase/logger@npm:0.4.2": - version: 0.4.2 - resolution: "@firebase/logger@npm:0.4.2" +"@firebase/logger@npm:0.4.4": + version: 0.4.4 + resolution: "@firebase/logger@npm:0.4.4" dependencies: tslib: "npm:^2.1.0" - checksum: 10/961b4605220c0a56c5f3ccf4e6049e44c27303c1ca998c6fa1d19de785c76d93e3b1a3da455e9f40655711345d8d779912366e4f369d93eda8d08c407cc5b140 + checksum: 10/fb47ac92c86a77f997cef19775afd97edc7e46a28d8c10e2829b2f343da6115c73b9108a34d52f419cf7789c596af53177bf4a9d06dc53e2a31427e448ba347e languageName: node linkType: hard -"@firebase/messaging-compat@npm:0.2.10": - version: 0.2.10 - resolution: "@firebase/messaging-compat@npm:0.2.10" +"@firebase/messaging-compat@npm:0.2.16": + version: 0.2.16 + resolution: "@firebase/messaging-compat@npm:0.2.16" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/messaging": "npm:0.12.10" - "@firebase/util": "npm:1.9.7" + "@firebase/component": "npm:0.6.12" + "@firebase/messaging": "npm:0.12.16" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app-compat": 0.x - checksum: 10/3565546cc935f553c0331dc86e593cd39e09c198c549e801dfb9d4ee53152fcc3bab277355bf1a82a063506b8462038dbd3d2122178b9c1edd39c08a0503244d + checksum: 10/1887599e3f7d7db5a70f923118eda769130aa134c6a6ba0a9f599c541d78b2e00b9548fc51c12f430c60a6e902221fe951a4beeddd674f1c042ffa32d1593dc9 languageName: node linkType: hard -"@firebase/messaging-interop-types@npm:0.2.2": - version: 0.2.2 - resolution: "@firebase/messaging-interop-types@npm:0.2.2" - checksum: 10/547f8ebf2c5a8dcbc484991b97d76bd3dc3eb4bd9d4e6ea2ffc652097c7065d92dc68d389ddb19fba41e0ce3b5f4cd757ed22f96b4744801149b0f8dbf323af7 +"@firebase/messaging-interop-types@npm:0.2.3": + version: 0.2.3 + resolution: "@firebase/messaging-interop-types@npm:0.2.3" + checksum: 10/3359f2675d884f7908c7c0146098db6a6f88ba4d91021f822edb638633a3fc7f6554e647a71f44265ec7afc40e6b26a4824afeb0ee3883110bb77ceff4b95c14 languageName: node linkType: hard -"@firebase/messaging@npm:0.12.10": - version: 0.12.10 - resolution: "@firebase/messaging@npm:0.12.10" +"@firebase/messaging@npm:0.12.16": + version: 0.12.16 + resolution: "@firebase/messaging@npm:0.12.16" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/installations": "npm:0.6.8" - "@firebase/messaging-interop-types": "npm:0.2.2" - "@firebase/util": "npm:1.9.7" + "@firebase/component": "npm:0.6.12" + "@firebase/installations": "npm:0.6.12" + "@firebase/messaging-interop-types": "npm:0.2.3" + "@firebase/util": "npm:1.10.3" idb: "npm:7.1.1" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app": 0.x - checksum: 10/c883b465da2cdee0e08f37119bcb4eb1152bec482fe87136f4dbc6fc9ffbbba1f7c25fff70948844633949ab77bc38d81afd31e7227bf398863cccb878be3095 + checksum: 10/e237f35c4b179a521a6a37255fa719784ec73f30b76d179c059f21bf1e7ee6f907299c137a7b55496134dc5c3578d365c62b2e44988323edd3d96e5468f016d6 languageName: node linkType: hard -"@firebase/performance-compat@npm:0.2.8": - version: 0.2.8 - resolution: "@firebase/performance-compat@npm:0.2.8" +"@firebase/performance-compat@npm:0.2.12": + version: 0.2.12 + resolution: "@firebase/performance-compat@npm:0.2.12" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/logger": "npm:0.4.2" - "@firebase/performance": "npm:0.6.8" - "@firebase/performance-types": "npm:0.2.2" - "@firebase/util": "npm:1.9.7" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/performance": "npm:0.6.12" + "@firebase/performance-types": "npm:0.2.3" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app-compat": 0.x - checksum: 10/354a31f31c0d07df10a2b33f4ef2b34ba931cb51738c533c5af9e85c1645d9421f2262ec72eb54a3821f1df52644254f92b866d836c528d6a8ff91b399a2aad4 + checksum: 10/c171273df3994da6687a8e02dd7f046cd749d80d18e1bc241e1e8fd55f4d05578bcdd3924153fbf7175da2a0b88dc8fb6e7de98afe72dd1a36e54f96e807dea1 languageName: node linkType: hard -"@firebase/performance-types@npm:0.2.2": - version: 0.2.2 - resolution: "@firebase/performance-types@npm:0.2.2" - checksum: 10/d25ae06cb75ab6b44ffacf7affadc1f651881f283e58381c444eb63b62dfb74c33c544ab89843518ec1d15367ba7c4343b4d6b22de1f1df35126a1283baa578d +"@firebase/performance-types@npm:0.2.3": + version: 0.2.3 + resolution: "@firebase/performance-types@npm:0.2.3" + checksum: 10/1c9724ce59db4bddfed90627fe47d47877a51c33fc3e9dea0417c54adec2cf812ab8e90b6f15c7d6992823cb7d4a47e255ac33de221a1470d2e2c80342de1a10 languageName: node linkType: hard -"@firebase/performance@npm:0.6.8": - version: 0.6.8 - resolution: "@firebase/performance@npm:0.6.8" +"@firebase/performance@npm:0.6.12": + version: 0.6.12 + resolution: "@firebase/performance@npm:0.6.12" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/installations": "npm:0.6.8" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.9.7" + "@firebase/component": "npm:0.6.12" + "@firebase/installations": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app": 0.x - checksum: 10/5c989d154daea84f009f221245231bf5050cf0d96688bf1b20add98429088c815cc6e76e91180394c9d7fe5759094bbe4261c13604ff349c67e3d7113959b146 + checksum: 10/68f802e2a1f0add51e2346049957487561d1f59f9ea57f8447d7ba771210aee875aaa144d7db56bb376bac3509d800e917e6c3560e3dbf19bdc60c6e1bc67766 languageName: node linkType: hard -"@firebase/remote-config-compat@npm:0.2.8": - version: 0.2.8 - resolution: "@firebase/remote-config-compat@npm:0.2.8" +"@firebase/remote-config-compat@npm:0.2.12": + version: 0.2.12 + resolution: "@firebase/remote-config-compat@npm:0.2.12" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/logger": "npm:0.4.2" - "@firebase/remote-config": "npm:0.4.8" - "@firebase/remote-config-types": "npm:0.3.2" - "@firebase/util": "npm:1.9.7" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/remote-config": "npm:0.5.0" + "@firebase/remote-config-types": "npm:0.4.0" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app-compat": 0.x - checksum: 10/342b27720635c7f68ce8953cd22a5badfc628c3fb9414b32d40a2eaacb2feb2068079eac15c3c811f4327a06d01aa71631a6f2cfe8b80460f1910cfd37126342 + checksum: 10/931c4739c2b11b2719076630f09f5aa18f9edf8e89cf35c9b9a3a8cc5afc497c86e68cca165e1416afcb0b8040ed04363c676d31118fdcf4bf3823ef9172785c languageName: node linkType: hard -"@firebase/remote-config-types@npm:0.3.2": - version: 0.3.2 - resolution: "@firebase/remote-config-types@npm:0.3.2" - checksum: 10/6c91599c653825708aba9fe9e4562997f108c3e4f3eaf5d188f31c859a6ad013414aa7a213b6b021b68049dd0dd57158546dbc9fb64384652274ef7f57ce7d7d +"@firebase/remote-config-types@npm:0.4.0": + version: 0.4.0 + resolution: "@firebase/remote-config-types@npm:0.4.0" + checksum: 10/67de8c448412974bdbdc10b6bca90d957fa81f967553ff9a4aee316d374f9ebb3a24fa2541af639c1a1ece79070fab0ab64c925bcf6bb807e212cba3297e5ddf languageName: node linkType: hard -"@firebase/remote-config@npm:0.4.8": - version: 0.4.8 - resolution: "@firebase/remote-config@npm:0.4.8" +"@firebase/remote-config@npm:0.5.0": + version: 0.5.0 + resolution: "@firebase/remote-config@npm:0.5.0" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/installations": "npm:0.6.8" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.9.7" + "@firebase/component": "npm:0.6.12" + "@firebase/installations": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app": 0.x - checksum: 10/58f934b8cdc8582f260a8caf5b903d484d74aecba420221bf57e5df28f8ba7d3f9ca1ab58946c90ab2c29659bbfdad679fc59247468068063a5329d92cd27613 + checksum: 10/58a6fad255d3975700e65d4d19ec3360703f920bcbd3bd2ff21f239367af7405bfec5fddf3f800fb405dd4e4456f73cdf0c5cbf624a9512d77293f7cf14b64d8 languageName: node linkType: hard -"@firebase/storage-compat@npm:0.3.10": - version: 0.3.10 - resolution: "@firebase/storage-compat@npm:0.3.10" +"@firebase/storage-compat@npm:0.3.15": + version: 0.3.15 + resolution: "@firebase/storage-compat@npm:0.3.15" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/storage": "npm:0.13.0" - "@firebase/storage-types": "npm:0.8.2" - "@firebase/util": "npm:1.9.7" + "@firebase/component": "npm:0.6.12" + "@firebase/storage": "npm:0.13.5" + "@firebase/storage-types": "npm:0.8.3" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app-compat": 0.x - checksum: 10/63486c25c9b241ad5a55d9ab0a448ee9fc6a53b39733709ea56bce26e32cbdd1e8d4a22601a233ee18734ba1111a7ee52ca0196c281d0f4a88061a555306efb1 + checksum: 10/a4a4c64c44ea914a9509061ec373f33278f7096a547e7a9ed55c9100562bd688ca4f14f15eb3a798d5075b0e18dc15801bb95b23eddb2da600d855ee6c69e745 languageName: node linkType: hard -"@firebase/storage-types@npm:0.8.2": - version: 0.8.2 - resolution: "@firebase/storage-types@npm:0.8.2" +"@firebase/storage-types@npm:0.8.3": + version: 0.8.3 + resolution: "@firebase/storage-types@npm:0.8.3" peerDependencies: "@firebase/app-types": 0.x "@firebase/util": 1.x - checksum: 10/e00716932370d2004dc9f7ef6d7e3aff72305b91569fa6ec15e8bc2ec784b03a150391e8be2c063234edbbfda7796da915d48e26ce2f1f7c5d3343acd39afd99 + checksum: 10/ffee882352ec2d475d4cebc13a01d150621a2e4842b4b252ba12d731d68c4d3c0a03181202192af04014e3fb61c0d6fc51f9929985cc67e25948daa223159fc6 languageName: node linkType: hard -"@firebase/storage@npm:0.13.0": - version: 0.13.0 - resolution: "@firebase/storage@npm:0.13.0" +"@firebase/storage@npm:0.13.5": + version: 0.13.5 + resolution: "@firebase/storage@npm:0.13.5" dependencies: - "@firebase/component": "npm:0.6.8" - "@firebase/util": "npm:1.9.7" + "@firebase/component": "npm:0.6.12" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" - undici: "npm:5.28.4" peerDependencies: "@firebase/app": 0.x - checksum: 10/b33708f14ccb4aa7755a1e4dea3ce483aec0bd077bb9db8df4238f4c96f80e39ef687940ece39c3fa7dcceb0f6e5f3a22874f19dc8d05246226d4c1637508158 + checksum: 10/89acbd41d3ed9bffe7a37e293b0dc572622c196665db2821d76690ee205397f3f331666c24b5c63c14caaadb3e519b3489400a6c5387e78d4fe0c97fe75128a9 languageName: node linkType: hard -"@firebase/util@npm:1.9.7": - version: 1.9.7 - resolution: "@firebase/util@npm:1.9.7" +"@firebase/util@npm:1.10.3": + version: 1.10.3 + resolution: "@firebase/util@npm:1.10.3" dependencies: tslib: "npm:^2.1.0" - checksum: 10/c31290f45794af68a3ab571db1c0e3cb4d15443adfdc50107b835274b4ad525f839ee79a0da2898dd8b31e64ff811c126d338b0bab117be59c0a065ce984a89a + checksum: 10/8e5e1664a09798348abfa0cd138157943f8ee9c6e3804e6cb1dcff004b351a03f14f4b2711338133bb89f7f824546664af2c2aa98e229becbc9294cdddeecc99 languageName: node linkType: hard -"@firebase/vertexai-preview@npm:0.0.3": - version: 0.0.3 - resolution: "@firebase/vertexai-preview@npm:0.0.3" +"@firebase/vertexai@npm:1.0.3": + version: 1.0.3 + resolution: "@firebase/vertexai@npm:1.0.3" dependencies: - "@firebase/app-check-interop-types": "npm:0.3.2" - "@firebase/component": "npm:0.6.8" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.9.7" + "@firebase/app-check-interop-types": "npm:0.3.3" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app": 0.x "@firebase/app-types": 0.x - checksum: 10/490ea78f153b764e117989cb0ee9abeb0f456c6daefc58aa949147b1404a2d90d49c84a04556f8d84a729692ca99ed670b9dd9b37169b93ac01dc8d9242dac13 + checksum: 10/67b0ac231a547ac99bef3a549199fbaa67271fe93c1dc2af48bfebcf8ac1a7ea45bec6c633b8ac3ad28b089a6601e2b352c68c53065242dccac07a20a887d6cd languageName: node linkType: hard -"@firebase/webchannel-wrapper@npm:1.0.1": - version: 1.0.1 - resolution: "@firebase/webchannel-wrapper@npm:1.0.1" - checksum: 10/22fc7e1e6dd36ab7c13f3a6c1ff51f4d405304424dc323cb146109e7a3ab3b592e2ddb29f53197ee5719a8448cdedb98d9e86a080f9365e389f8429b1c6555c2 +"@firebase/webchannel-wrapper@npm:1.0.3": + version: 1.0.3 + resolution: "@firebase/webchannel-wrapper@npm:1.0.3" + checksum: 10/f4b491274855bd7b33b0339896c9f62049aab0de034f3196493531d0f4a19242a281570293e12b36b5ebfc8ba898e0329036646ff2b0f9a9b1f7d86f4e4593b4 languageName: node linkType: hard @@ -2297,22 +2340,23 @@ __metadata: languageName: node linkType: hard -"@metamask/accounts-controller@npm:^21.0.2, @metamask/accounts-controller@workspace:packages/accounts-controller": +"@metamask/accounts-controller@npm:^24.0.0, @metamask/accounts-controller@workspace:packages/accounts-controller": version: 0.0.0-use.local resolution: "@metamask/accounts-controller@workspace:packages/accounts-controller" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/eth-snap-keyring": "npm:^8.1.1" - "@metamask/keyring-api": "npm:^14.0.0" - "@metamask/keyring-controller": "npm:^19.0.4" - "@metamask/keyring-internal-api": "npm:^2.0.1" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/eth-snap-keyring": "npm:^10.0.0" + "@metamask/keyring-api": "npm:^17.0.0" + "@metamask/keyring-controller": "npm:^19.1.0" + "@metamask/keyring-internal-api": "npm:^4.0.1" + "@metamask/network-controller": "npm:^22.2.1" "@metamask/providers": "npm:^18.1.1" - "@metamask/snaps-controllers": "npm:^9.10.0" - "@metamask/snaps-sdk": "npm:^6.7.0" - "@metamask/snaps-utils": "npm:^8.3.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/snaps-controllers": "npm:^9.19.0" + "@metamask/snaps-sdk": "npm:^6.17.1" + "@metamask/snaps-utils": "npm:^8.10.0" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" "@types/readable-stream": "npm:^2.3.0" deepmerge: "npm:^4.2.2" @@ -2327,8 +2371,9 @@ __metadata: webextension-polyfill: "npm:^0.12.0" peerDependencies: "@metamask/keyring-controller": ^19.0.0 + "@metamask/network-controller": ^22.0.0 "@metamask/providers": ^18.1.0 - "@metamask/snaps-controllers": ^9.7.0 + "@metamask/snaps-controllers": ^9.19.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 languageName: unknown linkType: soft @@ -2349,9 +2394,9 @@ __metadata: resolution: "@metamask/address-book-controller@workspace:packages/address-book-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" - "@metamask/utils": "npm:^11.0.1" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -2367,7 +2412,7 @@ __metadata: resolution: "@metamask/announcement-controller@workspace:packages/announcement-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" + "@metamask/base-controller": "npm:^8.0.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -2385,14 +2430,14 @@ __metadata: languageName: node linkType: hard -"@metamask/approval-controller@npm:^7.1.1, @metamask/approval-controller@npm:^7.1.2, @metamask/approval-controller@workspace:packages/approval-controller": +"@metamask/approval-controller@npm:^7.1.2, @metamask/approval-controller@npm:^7.1.3, @metamask/approval-controller@workspace:packages/approval-controller": version: 0.0.0-use.local resolution: "@metamask/approval-controller@workspace:packages/approval-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" + "@metamask/base-controller": "npm:^8.0.0" "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -2417,28 +2462,29 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/accounts-controller": "npm:^21.0.2" - "@metamask/approval-controller": "npm:^7.1.2" + "@metamask/accounts-controller": "npm:^24.0.0" + "@metamask/approval-controller": "npm:^7.1.3" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" + "@metamask/base-controller": "npm:^8.0.0" "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.4.5" + "@metamask/controller-utils": "npm:^11.5.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-provider-http": "npm:^0.3.0" - "@metamask/keyring-api": "npm:^14.0.0" - "@metamask/keyring-controller": "npm:^19.0.4" - "@metamask/keyring-internal-api": "npm:^2.0.1" - "@metamask/keyring-snap-client": "npm:^3.0.0" + "@metamask/keyring-api": "npm:^17.0.0" + "@metamask/keyring-controller": "npm:^19.1.0" + "@metamask/keyring-internal-api": "npm:^4.0.1" + "@metamask/keyring-snap-client": "npm:^3.0.3" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^22.1.1" - "@metamask/polling-controller": "npm:^12.0.2" - "@metamask/preferences-controller": "npm:^15.0.1" + "@metamask/network-controller": "npm:^22.2.1" + "@metamask/permission-controller": "npm:^11.0.6" + "@metamask/polling-controller": "npm:^12.0.3" + "@metamask/preferences-controller": "npm:^15.0.2" "@metamask/providers": "npm:^18.1.1" "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/snaps-controllers": "npm:^9.10.0" - "@metamask/snaps-sdk": "npm:^6.7.0" - "@metamask/snaps-utils": "npm:^8.3.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/snaps-controllers": "npm:^9.19.0" + "@metamask/snaps-sdk": "npm:^6.17.1" + "@metamask/snaps-utils": "npm:^8.10.0" + "@metamask/utils": "npm:^11.1.0" "@types/bn.js": "npm:^5.1.5" "@types/jest": "npm:^27.4.1" "@types/lodash": "npm:^4.14.191" @@ -2447,7 +2493,6 @@ __metadata: async-mutex: "npm:^0.5.0" bitcoin-address-validation: "npm:^2.2.3" bn.js: "npm:^5.2.1" - cockatiel: "npm:^3.1.2" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" jest: "npm:^27.5.1" @@ -2464,12 +2509,14 @@ __metadata: uuid: "npm:^8.3.2" webextension-polyfill: "npm:^0.12.0" peerDependencies: - "@metamask/accounts-controller": ^21.0.0 + "@metamask/accounts-controller": ^24.0.0 "@metamask/approval-controller": ^7.0.0 "@metamask/keyring-controller": ^19.0.0 "@metamask/network-controller": ^22.0.0 + "@metamask/permission-controller": ^11.0.0 "@metamask/preferences-controller": ^15.0.0 "@metamask/providers": ^18.1.0 + "@metamask/snaps-controllers": ^9.19.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 languageName: unknown linkType: soft @@ -2505,13 +2552,23 @@ __metadata: languageName: node linkType: hard -"@metamask/base-controller@npm:^7.0.2, @metamask/base-controller@npm:^7.1.1, @metamask/base-controller@workspace:packages/base-controller": +"@metamask/base-controller@npm:^7.0.3, @metamask/base-controller@npm:^7.1.1": + version: 7.1.1 + resolution: "@metamask/base-controller@npm:7.1.1" + dependencies: + "@metamask/utils": "npm:^11.0.1" + immer: "npm:^9.0.6" + checksum: 10/d45abc9e0f3f42a0ea7f0a52734f3749fafc5fefc73608230ab0815578e83a9fc28fe57dc7000f6f8df2cdcee5b53f68bb971091075bec9de6b7f747de627c60 + languageName: node + linkType: hard + +"@metamask/base-controller@npm:^8.0.0, @metamask/base-controller@workspace:packages/base-controller": version: 0.0.0-use.local resolution: "@metamask/base-controller@workspace:packages/base-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/json-rpc-engine": "npm:^10.0.2" - "@metamask/utils": "npm:^11.0.1" + "@metamask/json-rpc-engine": "npm:^10.0.3" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" "@types/sinon": "npm:^9.0.10" deepmerge: "npm:^4.2.2" @@ -2525,6 +2582,39 @@ __metadata: languageName: unknown linkType: soft +"@metamask/bridge-controller@workspace:packages/bridge-controller": + version: 0.0.0-use.local + resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" + dependencies: + "@metamask/accounts-controller": "npm:^24.0.0" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" + "@metamask/eth-json-rpc-provider": "npm:^4.1.8" + "@metamask/json-rpc-engine": "npm:^10.0.3" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/network-controller": "npm:^22.2.1" + "@metamask/polling-controller": "npm:^12.0.3" + "@metamask/transaction-controller": "npm:^46.0.0" + "@metamask/utils": "npm:^11.1.0" + "@types/jest": "npm:^27.4.1" + deepmerge: "npm:^4.2.2" + ethers: "npm:^6.12.0" + jest: "npm:^27.5.1" + jest-environment-jsdom: "npm:^27.5.1" + lodash: "npm:^4.17.21" + nock: "npm:^13.3.1" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.2.2" + peerDependencies: + "@metamask/accounts-controller": ^24.0.0 + "@metamask/network-controller": ^22.0.0 + "@metamask/transaction-controller": ^46.0.0 + languageName: unknown + linkType: soft + "@metamask/browser-passworder@npm:^4.3.0": version: 4.3.0 resolution: "@metamask/browser-passworder@npm:4.3.0" @@ -2539,7 +2629,7 @@ __metadata: resolution: "@metamask/build-utils@workspace:packages/build-utils" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" "@types/eslint": "npm:^8.44.7" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" @@ -2556,8 +2646,8 @@ __metadata: resolution: "@metamask/composable-controller@workspace:packages/composable-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/json-rpc-engine": "npm:^10.0.2" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/json-rpc-engine": "npm:^10.0.3" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" @@ -2577,7 +2667,7 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^11.4.5, @metamask/controller-utils@workspace:packages/controller-utils": +"@metamask/controller-utils@npm:^11.5.0, @metamask/controller-utils@workspace:packages/controller-utils": version: 0.0.0-use.local resolution: "@metamask/controller-utils@workspace:packages/controller-utils" dependencies: @@ -2586,7 +2676,7 @@ __metadata: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-unit": "npm:^0.3.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" "@spruceid/siwe-parser": "npm:2.1.0" "@types/bn.js": "npm:^5.1.5" "@types/jest": "npm:^27.4.1" @@ -2624,9 +2714,9 @@ __metadata: "@metamask/eslint-config-nodejs": "npm:^14.0.0" "@metamask/eslint-config-typescript": "npm:^14.0.0" "@metamask/eth-block-tracker": "npm:^11.0.3" - "@metamask/eth-json-rpc-provider": "npm:^4.1.7" - "@metamask/json-rpc-engine": "npm:^10.0.2" - "@metamask/utils": "npm:^11.0.1" + "@metamask/eth-json-rpc-provider": "npm:^4.1.8" + "@metamask/json-rpc-engine": "npm:^10.0.3" + "@metamask/utils": "npm:^11.1.0" "@ts-bridge/cli": "npm:^0.6.1" "@types/jest": "npm:^27.4.1" "@types/lodash": "npm:^4.14.191" @@ -2639,6 +2729,7 @@ __metadata: "@yarnpkg/fslib": "npm:^3.1.1" "@yarnpkg/types": "npm:^4.0.0" babel-jest: "npm:^29.7.0" + chalk: "npm:^4.1.2" depcheck: "npm:^1.4.7" eslint: "npm:^9.11.0" eslint-config-prettier: "npm:^9.1.0" @@ -2656,6 +2747,7 @@ __metadata: lodash: "npm:^4.17.21" nock: "npm:^13.3.1" prettier: "npm:^3.3.3" + prettier-2: "npm:prettier@^2.8.8" prettier-plugin-packagejson: "npm:^2.4.5" rimraf: "npm:^5.0.5" semver: "npm:^7.6.3" @@ -2689,16 +2781,40 @@ __metadata: languageName: node linkType: hard +"@metamask/earn-controller@workspace:packages/earn-controller": + version: 0.0.0-use.local + resolution: "@metamask/earn-controller@workspace:packages/earn-controller" + dependencies: + "@ethersproject/providers": "npm:^5.7.0" + "@metamask/accounts-controller": "npm:^24.0.0" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" + "@metamask/network-controller": "npm:^22.2.1" + "@metamask/stake-sdk": "npm:^1.0.0" + "@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.2.2" + peerDependencies: + "@metamask/accounts-controller": ^24.0.0 + "@metamask/network-controller": ^22.1.1 + languageName: unknown + linkType: soft + "@metamask/ens-controller@workspace:packages/ens-controller": version: 0.0.0-use.local resolution: "@metamask/ens-controller@workspace:packages/ens-controller" dependencies: "@ethersproject/providers": "npm:^5.7.0" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" - "@metamask/network-controller": "npm:^22.1.1" - "@metamask/utils": "npm:^11.0.1" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" + "@metamask/network-controller": "npm:^22.2.1" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -2775,16 +2891,16 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-block-tracker@npm:^11.0.3": - version: 11.0.3 - resolution: "@metamask/eth-block-tracker@npm:11.0.3" +"@metamask/eth-block-tracker@npm:^11.0.3, @metamask/eth-block-tracker@npm:^11.0.4": + version: 11.0.4 + resolution: "@metamask/eth-block-tracker@npm:11.0.4" dependencies: "@metamask/eth-json-rpc-provider": "npm:^4.1.5" "@metamask/safe-event-emitter": "npm:^3.1.1" - "@metamask/utils": "npm:^9.1.0" + "@metamask/utils": "npm:^11.0.1" json-rpc-random-id: "npm:^1.0.1" pify: "npm:^5.0.0" - checksum: 10/c73a570f889c613ab309643c84a4aed1a4eeed5c101434da84b34babe2352218c65f863602e013a8a55052e3f80a538efed865cc5fb7af558d168c52c5a399a4 + checksum: 10/56b60255a3ae23a378570a49c30d0c13bd74094c0509a978cad20ef57079c80bae91fd35749acb9ac5feef2922eec45a6fef8c0ee6e754cbf3722f8e5d0d771e languageName: node linkType: hard @@ -2814,38 +2930,38 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-json-rpc-infura@npm:^10.0.0": - version: 10.0.0 - resolution: "@metamask/eth-json-rpc-infura@npm:10.0.0" +"@metamask/eth-json-rpc-infura@npm:^10.1.0": + version: 10.1.0 + resolution: "@metamask/eth-json-rpc-infura@npm:10.1.0" dependencies: - "@metamask/eth-json-rpc-provider": "npm:^4.1.5" - "@metamask/json-rpc-engine": "npm:^10.0.0" - "@metamask/rpc-errors": "npm:^7.0.0" - "@metamask/utils": "npm:^9.1.0" - checksum: 10/17e0147ff86c48107983035e9bda4d16fba321ee0e29733347e9338a4c795c506a2ffd643c44c9d5334886696412cf288f852d06311fed0d76edc8847ee6b8de + "@metamask/eth-json-rpc-provider": "npm:^4.1.7" + "@metamask/json-rpc-engine": "npm:^10.0.2" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/utils": "npm:^11.0.1" + checksum: 10/e3305d8a2535c3dd0e4b127fb6d01e70245f394b05c6fe81030a9043ad6fd4b8d904e00830236f88cb80b09fa6490ea22e7abaa8230a4fd4912436d0738ee702 languageName: node linkType: hard -"@metamask/eth-json-rpc-middleware@npm:^15.0.1": - version: 15.0.1 - resolution: "@metamask/eth-json-rpc-middleware@npm:15.0.1" +"@metamask/eth-json-rpc-middleware@npm:^15.1.0": + version: 15.2.0 + resolution: "@metamask/eth-json-rpc-middleware@npm:15.2.0" dependencies: - "@metamask/eth-block-tracker": "npm:^11.0.3" - "@metamask/eth-json-rpc-provider": "npm:^4.1.5" - "@metamask/eth-sig-util": "npm:^7.0.3" - "@metamask/json-rpc-engine": "npm:^10.0.0" - "@metamask/rpc-errors": "npm:^7.0.0" - "@metamask/utils": "npm:^9.1.0" + "@metamask/eth-block-tracker": "npm:^11.0.4" + "@metamask/eth-json-rpc-provider": "npm:^4.1.7" + "@metamask/eth-sig-util": "npm:^8.1.2" + "@metamask/json-rpc-engine": "npm:^10.0.2" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/utils": "npm:^11.1.0" "@types/bn.js": "npm:^5.1.5" bn.js: "npm:^5.2.1" klona: "npm:^2.0.6" pify: "npm:^5.0.0" safe-stable-stringify: "npm:^2.4.3" - checksum: 10/9777fca31440bf0076f5d2c24e2ddb4848ecd9d41b0a5d6114c27339567e60bfcb9057d6bfa81f18f5ca0ffa848ecf9603c765f606b8de206d3e34dba519c501 + checksum: 10/52dcb5927fe5e2db318965e3c5179704a1fa56ebccabeda93b8f9a6c28cb8958d5fefd7bddf5673c6532eab5d46ced8c7001394ce5cc634d8acd491755bcdd4c languageName: node linkType: hard -"@metamask/eth-json-rpc-provider@npm:^4.1.5, @metamask/eth-json-rpc-provider@npm:^4.1.7, @metamask/eth-json-rpc-provider@workspace:packages/eth-json-rpc-provider": +"@metamask/eth-json-rpc-provider@npm:^4.1.5, @metamask/eth-json-rpc-provider@npm:^4.1.7, @metamask/eth-json-rpc-provider@npm:^4.1.8, @metamask/eth-json-rpc-provider@workspace:packages/eth-json-rpc-provider": version: 0.0.0-use.local resolution: "@metamask/eth-json-rpc-provider@workspace:packages/eth-json-rpc-provider" dependencies: @@ -2853,10 +2969,10 @@ __metadata: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-query": "npm:^0.5.3" - "@metamask/json-rpc-engine": "npm:^10.0.2" + "@metamask/json-rpc-engine": "npm:^10.0.3" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/safe-event-emitter": "npm:^3.0.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" ethers: "npm:^6.12.0" @@ -2893,17 +3009,18 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-sig-util@npm:^8.0.0, @metamask/eth-sig-util@npm:^8.1.2": - version: 8.1.2 - resolution: "@metamask/eth-sig-util@npm:8.1.2" +"@metamask/eth-sig-util@npm:^8.0.0, @metamask/eth-sig-util@npm:^8.1.2, @metamask/eth-sig-util@npm:^8.2.0": + version: 8.2.0 + resolution: "@metamask/eth-sig-util@npm:8.2.0" dependencies: + "@ethereumjs/rlp": "npm:^4.0.1" "@ethereumjs/util": "npm:^8.1.0" "@metamask/abi-utils": "npm:^3.0.0" "@metamask/utils": "npm:^11.0.1" "@scure/base": "npm:~1.1.3" ethereum-cryptography: "npm:^2.1.2" tweetnacl: "npm:^1.0.3" - checksum: 10/32b284fc8c3229e3741b1c21f44ca3f55c2215ef8ad700775cd9501bbaab56a4e861827bef24ed263734d28c899eb3b34a9646e9d21ec3fce12204b7eb58bfed + checksum: 10/385df1ec541116e1bd725a1df1a519996bad167f99d1b2677126e398cdfda6fc3f03d2ff8f1ca523966bc0aae3ea92a9050953a45d5a7711f4128aacf9242bfc languageName: node linkType: hard @@ -2920,28 +3037,24 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-snap-keyring@npm:^8.1.1": - version: 8.1.1 - resolution: "@metamask/eth-snap-keyring@npm:8.1.1" +"@metamask/eth-snap-keyring@npm:^10.0.0": + version: 10.0.0 + resolution: "@metamask/eth-snap-keyring@npm:10.0.0" dependencies: "@ethereumjs/tx": "npm:^4.2.0" - "@metamask/eth-sig-util": "npm:^8.1.2" - "@metamask/keyring-api": "npm:^14.0.0" - "@metamask/keyring-internal-api": "npm:^2.0.1" - "@metamask/keyring-internal-snap-client": "npm:^3.0.0" - "@metamask/keyring-utils": "npm:^1.2.0" - "@metamask/snaps-controllers": "npm:^9.10.0" - "@metamask/snaps-sdk": "npm:^6.7.0" - "@metamask/snaps-utils": "npm:^8.3.0" + "@metamask/base-controller": "npm:^7.1.1" + "@metamask/eth-sig-util": "npm:^8.2.0" + "@metamask/keyring-api": "npm:^17.0.0" + "@metamask/keyring-internal-api": "npm:^4.0.2" + "@metamask/keyring-internal-snap-client": "npm:^4.0.0" + "@metamask/keyring-utils": "npm:^2.0.0" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" "@types/uuid": "npm:^9.0.8" uuid: "npm:^9.0.1" - webextension-polyfill: "npm:^0.12.0" peerDependencies: - "@metamask/keyring-api": ^14.0.0 - "@metamask/providers": ^18.3.1 - checksum: 10/43f373e67cf6989e1808514b7708288ed64bd79930fe511b7a4482f7464a8b418330d5f8ca8799afd098cefc41bbcf9b6eb00fe534f3b8101f352692c3ad3343 + "@metamask/keyring-api": ^17.0.0 + checksum: 10/df3a9412cad8ebfe571fe1a3bb5ce0ab86a7557b61e9644eb757c8c23fa144367ab9458207f61b0b0854c69fddd4df697053bbe619adb1da93d18b56cfcae710 languageName: node linkType: hard @@ -3094,9 +3207,9 @@ __metadata: resolution: "@metamask/example-controllers@workspace:examples/example-controllers" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" - "@metamask/utils": "npm:^11.0.1" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -3108,19 +3221,19 @@ __metadata: languageName: unknown linkType: soft -"@metamask/gas-fee-controller@npm:^22.0.2, @metamask/gas-fee-controller@workspace:packages/gas-fee-controller": +"@metamask/gas-fee-controller@npm:^22.0.3, @metamask/gas-fee-controller@workspace:packages/gas-fee-controller": version: 0.0.0-use.local resolution: "@metamask/gas-fee-controller@workspace:packages/gas-fee-controller" dependencies: "@babel/runtime": "npm:^7.23.9" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-unit": "npm:^0.3.0" - "@metamask/network-controller": "npm:^22.1.1" - "@metamask/polling-controller": "npm:^12.0.2" - "@metamask/utils": "npm:^11.0.1" + "@metamask/network-controller": "npm:^22.2.1" + "@metamask/polling-controller": "npm:^12.0.3" + "@metamask/utils": "npm:^11.1.0" "@types/bn.js": "npm:^5.1.5" "@types/jest": "npm:^27.4.1" "@types/jest-when": "npm:^2.7.3" @@ -3142,7 +3255,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.1, @metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@workspace:packages/json-rpc-engine": +"@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@npm:^10.0.3, @metamask/json-rpc-engine@workspace:packages/json-rpc-engine": version: 0.0.0-use.local resolution: "@metamask/json-rpc-engine@workspace:packages/json-rpc-engine" dependencies: @@ -3151,7 +3264,7 @@ __metadata: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/safe-event-emitter": "npm:^3.0.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -3162,14 +3275,14 @@ __metadata: languageName: unknown linkType: soft -"@metamask/json-rpc-middleware-stream@npm:^8.0.5, @metamask/json-rpc-middleware-stream@workspace:packages/json-rpc-middleware-stream": +"@metamask/json-rpc-middleware-stream@npm:^8.0.6, @metamask/json-rpc-middleware-stream@workspace:packages/json-rpc-middleware-stream": version: 0.0.0-use.local resolution: "@metamask/json-rpc-middleware-stream@workspace:packages/json-rpc-middleware-stream" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/json-rpc-engine": "npm:^10.0.2" + "@metamask/json-rpc-engine": "npm:^10.0.3" "@metamask/safe-event-emitter": "npm:^3.0.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" "@types/readable-stream": "npm:^2.3.0" deepmerge: "npm:^4.2.2" @@ -3185,32 +3298,44 @@ __metadata: languageName: unknown linkType: soft -"@metamask/key-tree@npm:^9.1.2": - version: 9.1.2 - resolution: "@metamask/key-tree@npm:9.1.2" +"@metamask/key-tree@npm:^10.0.2": + version: 10.0.2 + resolution: "@metamask/key-tree@npm:10.0.2" dependencies: "@metamask/scure-bip39": "npm:^2.1.1" - "@metamask/utils": "npm:^9.0.0" + "@metamask/utils": "npm:^11.0.1" "@noble/curves": "npm:^1.2.0" "@noble/hashes": "npm:^1.3.2" "@scure/base": "npm:^1.0.0" - checksum: 10/9b178a4156b2f36bf630564dd0530c41c6356492971d2bcc8f979c79c81144945823a5b770e4097e12b89b42133b81f00c95a7b8fe9931ea1dd928989ee3c406 + checksum: 10/fd2e445c75dc3cd3976fdc38a5029ee71a6f4afcbbf5c9a17152bba70cf35df8095caa853ae62eef90a51b43f23eeb9546fc6eb7d93a099d82effe8dc7592259 languageName: node linkType: hard -"@metamask/keyring-api@npm:^14.0.0": - version: 14.0.0 - resolution: "@metamask/keyring-api@npm:14.0.0" +"@metamask/keyring-api@npm:^16.1.0": + version: 16.1.0 + resolution: "@metamask/keyring-api@npm:16.1.0" dependencies: - "@metamask/keyring-utils": "npm:^1.2.0" + "@metamask/keyring-utils": "npm:^2.0.0" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" + bech32: "npm:^2.0.0" + checksum: 10/6a3877e8e70b02728d4dc056a0eab5d961dd3089236539827ffb4194a3acdc9c71436cc3248ed1d6bf62d3dc0b6e69e2379177db6d690af1a77d4698767324fd + languageName: node + linkType: hard + +"@metamask/keyring-api@npm:^17.0.0": + version: 17.0.0 + resolution: "@metamask/keyring-api@npm:17.0.0" + dependencies: + "@metamask/keyring-utils": "npm:^2.0.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.1.0" bech32: "npm:^2.0.0" - checksum: 10/6064fb584cdf02536f9aeec3789b8df53ec6b21f89c1e6aa301e614916b04fabc0f0ea7221440d8b7af6798c1354b89bb9022afc094a6cfd7c8b482d3ec5a3fe + checksum: 10/0cf7283d8e4c665cbaf2658a90e7569b0bb582056aab702bdc0d98144eb8143437ed2b0feeca95e530d36741b0271f88f92f0d0a64dbd287b4314b91e03d2d4d languageName: node linkType: hard -"@metamask/keyring-controller@npm:^19.0.4, @metamask/keyring-controller@workspace:packages/keyring-controller": +"@metamask/keyring-controller@npm:^19.1.0, @metamask/keyring-controller@workspace:packages/keyring-controller": version: 0.0.0-use.local resolution: "@metamask/keyring-controller@workspace:packages/keyring-controller" dependencies: @@ -3222,16 +3347,16 @@ __metadata: "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" + "@metamask/base-controller": "npm:^8.0.0" "@metamask/browser-passworder": "npm:^4.3.0" "@metamask/eth-hd-keyring": "npm:^7.0.4" "@metamask/eth-sig-util": "npm:^8.0.0" "@metamask/eth-simple-keyring": "npm:^6.0.5" - "@metamask/keyring-api": "npm:^14.0.0" - "@metamask/keyring-internal-api": "npm:^2.0.1" - "@metamask/message-manager": "npm:^12.0.0" + "@metamask/keyring-api": "npm:^17.0.0" + "@metamask/keyring-internal-api": "npm:^4.0.1" + "@metamask/message-manager": "npm:^12.0.1" "@metamask/scure-bip39": "npm:^2.1.1" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" @@ -3248,69 +3373,80 @@ __metadata: languageName: unknown linkType: soft -"@metamask/keyring-internal-api@npm:^2.0.1": - version: 2.0.1 - resolution: "@metamask/keyring-internal-api@npm:2.0.1" +"@metamask/keyring-internal-api@npm:^4.0.1, @metamask/keyring-internal-api@npm:^4.0.2": + version: 4.0.2 + resolution: "@metamask/keyring-internal-api@npm:4.0.2" dependencies: - "@metamask/keyring-api": "npm:^14.0.0" - "@metamask/keyring-utils": "npm:^1.2.0" + "@metamask/keyring-api": "npm:^17.0.0" + "@metamask/keyring-utils": "npm:^2.0.0" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.0.1" - checksum: 10/f508fda22085455360423fa6c1ad690c7d19a79b9e42d2b27fe4098a7b5db211b1c74f4df8606c34ead4c0f202d624ddcafc66f5d93b6af2f8aa59deb04e1332 + "@metamask/utils": "npm:^11.1.0" + checksum: 10/2507026eef98e887b09107fb32d52c705301e6aa80f471a13be56116648f6a5f267a09b200a91cfadc59e3a496bbe34c95f570f65e1726f13a0d17fbfab699ae languageName: node linkType: hard -"@metamask/keyring-internal-snap-client@npm:^3.0.0": - version: 3.0.0 - resolution: "@metamask/keyring-internal-snap-client@npm:3.0.0" - dependencies: - "@metamask/keyring-api": "npm:^14.0.0" - "@metamask/keyring-snap-client": "npm:^3.0.0" - "@metamask/keyring-utils": "npm:^1.2.0" - "@metamask/snaps-controllers": "npm:^9.10.0" - "@metamask/snaps-sdk": "npm:^6.7.0" - "@metamask/snaps-utils": "npm:^8.3.0" +"@metamask/keyring-internal-snap-client@npm:^4.0.0": + version: 4.0.0 + resolution: "@metamask/keyring-internal-snap-client@npm:4.0.0" + dependencies: + "@metamask/base-controller": "npm:^7.1.1" + "@metamask/keyring-api": "npm:^17.0.0" + "@metamask/keyring-snap-client": "npm:^4.0.0" + "@metamask/keyring-utils": "npm:^2.0.0" + checksum: 10/817c9b332bdcdc9dab6a24566643e87dfcdee91345ec07673f142b98041809a05bee4ae7849ad95f832d2e97fccca0c339bcd6a53459d32808b56342af73ca8a + languageName: node + linkType: hard + +"@metamask/keyring-snap-client@npm:^3.0.3": + version: 3.0.3 + resolution: "@metamask/keyring-snap-client@npm:3.0.3" + dependencies: + "@metamask/keyring-api": "npm:^16.1.0" + "@metamask/keyring-utils": "npm:^2.0.0" + "@metamask/superstruct": "npm:^3.1.0" + "@types/uuid": "npm:^9.0.8" + uuid: "npm:^9.0.1" webextension-polyfill: "npm:^0.12.0" peerDependencies: "@metamask/providers": ^18.3.1 - checksum: 10/359597865c1501f1aacca406b38f1bddad1fc089d8a3ad9582939cd004ebbea713ffd2f2dd1568f424a5cc5bbf0f0166576e518f285d7a41df79c987e4969e82 + checksum: 10/f408b587380216b77ca0ff4d6f37c64d933392c6bac950c77a9df4a858dbc61c981a41b2cf3870b9041cb210566087e83398f3e7bbc82f39c0eb952eb990a3c8 languageName: node linkType: hard -"@metamask/keyring-snap-client@npm:^3.0.0": - version: 3.0.0 - resolution: "@metamask/keyring-snap-client@npm:3.0.0" +"@metamask/keyring-snap-client@npm:^4.0.0": + version: 4.0.0 + resolution: "@metamask/keyring-snap-client@npm:4.0.0" dependencies: - "@metamask/keyring-api": "npm:^14.0.0" - "@metamask/keyring-utils": "npm:^1.2.0" + "@metamask/keyring-api": "npm:^17.0.0" + "@metamask/keyring-utils": "npm:^2.0.0" "@metamask/superstruct": "npm:^3.1.0" "@types/uuid": "npm:^9.0.8" uuid: "npm:^9.0.1" webextension-polyfill: "npm:^0.12.0" peerDependencies: - "@metamask/providers": ^18.3.1 - checksum: 10/b7e90b0878210cfa7de2d0ab5cef82ee8007d1c71c1bc50436cd5f70aef633ae580e3ec818769cb38f7c6e8d7e4d610fad88c1e2ef63eaa32bc73dd15bf226fd + "@metamask/providers": ^19.0.0 + checksum: 10/c568ccaff799bd1a756e56c0b2aa1c7109bcda383726e2d55dd4e05817f3affc9be5a92484f90581fad506428fb9fb6999286f51f15e7f3b392bb851b53f0ab7 languageName: node linkType: hard -"@metamask/keyring-utils@npm:^1.2.0": - version: 1.2.0 - resolution: "@metamask/keyring-utils@npm:1.2.0" +"@metamask/keyring-utils@npm:^2.0.0": + version: 2.0.0 + resolution: "@metamask/keyring-utils@npm:2.0.0" dependencies: "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" bitcoin-address-validation: "npm:^2.2.3" - checksum: 10/685e70290717ec178c8c1caa4c2c7c21b8542a3665fd72e208722863063a07ba0d7d7293474c034733bf74572cb9e29fb9a2ab420ec64cd95aa6c2a78d2bb0d4 + checksum: 10/f7514821fb3bd5f5be575e0d74d5cf8becbdeac35a3e13dcd9e8bf789ba34aa2072783bdc3d0ddac479b97c986bcb54d77cdccedf5945d1c33ef310790e90efb languageName: node linkType: hard -"@metamask/logging-controller@npm:^6.0.3, @metamask/logging-controller@workspace:packages/logging-controller": +"@metamask/logging-controller@npm:^6.0.4, @metamask/logging-controller@workspace:packages/logging-controller": version: 0.0.0-use.local resolution: "@metamask/logging-controller@workspace:packages/logging-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -3322,15 +3458,15 @@ __metadata: languageName: unknown linkType: soft -"@metamask/message-manager@npm:^12.0.0, @metamask/message-manager@workspace:packages/message-manager": +"@metamask/message-manager@npm:^12.0.1, @metamask/message-manager@workspace:packages/message-manager": version: 0.0.0-use.local resolution: "@metamask/message-manager@workspace:packages/message-manager" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" "@metamask/eth-sig-util": "npm:^8.0.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" @@ -3351,22 +3487,49 @@ __metadata: languageName: node linkType: hard +"@metamask/multichain-network-controller@workspace:packages/multichain-network-controller": + version: 0.0.0-use.local + resolution: "@metamask/multichain-network-controller@workspace:packages/multichain-network-controller" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/keyring-api": "npm:^17.0.0" + "@metamask/keyring-controller": "npm:^19.1.0" + "@metamask/network-controller": "npm:^22.2.1" + "@metamask/utils": "npm:^11.1.0" + "@solana/addresses": "npm:^2.0.0" + "@types/jest": "npm:^27.4.1" + "@types/uuid": "npm:^8.3.0" + deepmerge: "npm:^4.2.2" + immer: "npm:^9.0.6" + jest: "npm:^27.5.1" + nock: "npm:^13.3.1" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.2.2" + peerDependencies: + "@metamask/accounts-controller": ^24.0.0 + "@metamask/network-controller": ^22.0.0 + languageName: unknown + linkType: soft + "@metamask/multichain-transactions-controller@workspace:packages/multichain-transactions-controller": version: 0.0.0-use.local resolution: "@metamask/multichain-transactions-controller@workspace:packages/multichain-transactions-controller" dependencies: - "@metamask/accounts-controller": "npm:^21.0.2" + "@metamask/accounts-controller": "npm:^24.0.0" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/keyring-api": "npm:^14.0.0" - "@metamask/keyring-controller": "npm:^19.0.4" - "@metamask/keyring-internal-api": "npm:^2.0.1" - "@metamask/keyring-snap-client": "npm:^3.0.0" - "@metamask/polling-controller": "npm:^12.0.2" - "@metamask/snaps-controllers": "npm:^9.10.0" - "@metamask/snaps-sdk": "npm:^6.7.0" - "@metamask/snaps-utils": "npm:^8.3.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/keyring-api": "npm:^17.0.0" + "@metamask/keyring-controller": "npm:^19.1.0" + "@metamask/keyring-internal-api": "npm:^4.0.1" + "@metamask/keyring-snap-client": "npm:^3.0.3" + "@metamask/polling-controller": "npm:^12.0.3" + "@metamask/snaps-controllers": "npm:^9.19.0" + "@metamask/snaps-sdk": "npm:^6.17.1" + "@metamask/snaps-utils": "npm:^8.10.0" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" @@ -3378,8 +3541,8 @@ __metadata: typescript: "npm:~5.2.2" uuid: "npm:^8.3.2" peerDependencies: - "@metamask/accounts-controller": ^21.0.0 - "@metamask/snaps-controllers": ^9.10.0 + "@metamask/accounts-controller": ^24.0.0 + "@metamask/snaps-controllers": ^9.19.0 languageName: unknown linkType: soft @@ -3389,14 +3552,14 @@ __metadata: dependencies: "@metamask/api-specs": "npm:^0.10.12" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/controller-utils": "npm:^11.4.5" + "@metamask/controller-utils": "npm:^11.5.0" "@metamask/eth-json-rpc-filters": "npm:^9.0.0" - "@metamask/json-rpc-engine": "npm:^10.0.2" - "@metamask/network-controller": "npm:^22.1.1" - "@metamask/permission-controller": "npm:^11.0.5" + "@metamask/json-rpc-engine": "npm:^10.0.3" + "@metamask/network-controller": "npm:^22.2.1" + "@metamask/permission-controller": "npm:^11.0.6" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/safe-event-emitter": "npm:^3.0.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" "@open-rpc/meta-schema": "npm:^1.14.6" "@open-rpc/schema-utils-js": "npm:^2.0.5" "@types/jest": "npm:^27.4.1" @@ -3419,9 +3582,9 @@ __metadata: resolution: "@metamask/name-controller@workspace:packages/name-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" - "@metamask/utils": "npm:^11.0.1" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" @@ -3433,26 +3596,27 @@ __metadata: languageName: unknown linkType: soft -"@metamask/network-controller@npm:^22.1.1, @metamask/network-controller@workspace:packages/network-controller": +"@metamask/network-controller@npm:^22.2.1, @metamask/network-controller@workspace:packages/network-controller": version: 0.0.0-use.local resolution: "@metamask/network-controller@workspace:packages/network-controller" dependencies: "@json-rpc-specification/meta-schema": "npm:^1.0.6" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" "@metamask/eth-block-tracker": "npm:^11.0.3" - "@metamask/eth-json-rpc-infura": "npm:^10.0.0" - "@metamask/eth-json-rpc-middleware": "npm:^15.0.1" - "@metamask/eth-json-rpc-provider": "npm:^4.1.7" + "@metamask/eth-json-rpc-infura": "npm:^10.1.0" + "@metamask/eth-json-rpc-middleware": "npm:^15.1.0" + "@metamask/eth-json-rpc-provider": "npm:^4.1.8" "@metamask/eth-query": "npm:^4.0.0" - "@metamask/json-rpc-engine": "npm:^10.0.2" + "@metamask/json-rpc-engine": "npm:^10.0.3" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/swappable-obj-proxy": "npm:^2.3.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" "@types/jest-when": "npm:^2.7.3" "@types/lodash": "npm:^4.14.191" + "@types/node-fetch": "npm:^2.6.12" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" fast-deep-equal: "npm:^3.1.3" @@ -3462,6 +3626,7 @@ __metadata: lodash: "npm:^4.17.21" loglevel: "npm:^1.8.1" nock: "npm:^13.3.1" + node-fetch: "npm:^2.7.0" reselect: "npm:^5.1.1" sinon: "npm:^9.2.4" ts-jest: "npm:^27.1.4" @@ -3493,17 +3658,17 @@ __metadata: "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" - "@metamask/keyring-controller": "npm:^19.0.4" - "@metamask/profile-sync-controller": "npm:^4.1.1" - "@metamask/utils": "npm:^11.0.1" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" + "@metamask/keyring-controller": "npm:^19.1.0" + "@metamask/profile-sync-controller": "npm:^8.0.0" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" "@types/readable-stream": "npm:^2.3.0" bignumber.js: "npm:^9.1.2" contentful: "npm:^10.15.0" deepmerge: "npm:^4.2.2" - firebase: "npm:^10.11.0" + firebase: "npm:^11.2.0" jest: "npm:^27.5.1" jest-environment-jsdom: "npm:^27.5.1" loglevel: "npm:^1.8.1" @@ -3515,7 +3680,7 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@metamask/keyring-controller": ^19.0.0 - "@metamask/profile-sync-controller": ^4.0.0 + "@metamask/profile-sync-controller": ^8.0.0 languageName: unknown linkType: soft @@ -3529,13 +3694,13 @@ __metadata: languageName: node linkType: hard -"@metamask/object-multiplex@npm:^2.0.0": - version: 2.0.0 - resolution: "@metamask/object-multiplex@npm:2.0.0" +"@metamask/object-multiplex@npm:^2.0.0, @metamask/object-multiplex@npm:^2.1.0": + version: 2.1.0 + resolution: "@metamask/object-multiplex@npm:2.1.0" dependencies: once: "npm:^1.4.0" readable-stream: "npm:^3.6.2" - checksum: 10/54baea752a3ac7c2742c376512e00d4902d383e9da8787574d3b21eb0081523309e24e3915a98f3ae0341d65712b6832d2eb7eeb862f4ef0da1ead52dcde5387 + checksum: 10/e119f695e89eb20c3174f8ac6d74587498d85cff92c37e83e167cb758b3d3147d5b5e1a997d6198d430ebcf2cede6265bf5d4513fe96dbb2d82bbc6167752caa languageName: node linkType: hard @@ -3549,17 +3714,17 @@ __metadata: languageName: node linkType: hard -"@metamask/permission-controller@npm:^11.0.3, @metamask/permission-controller@npm:^11.0.5, @metamask/permission-controller@workspace:packages/permission-controller": +"@metamask/permission-controller@npm:^11.0.5, @metamask/permission-controller@npm:^11.0.6, @metamask/permission-controller@workspace:packages/permission-controller": version: 0.0.0-use.local resolution: "@metamask/permission-controller@workspace:packages/permission-controller" dependencies: - "@metamask/approval-controller": "npm:^7.1.2" + "@metamask/approval-controller": "npm:^7.1.3" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" - "@metamask/json-rpc-engine": "npm:^10.0.2" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" + "@metamask/json-rpc-engine": "npm:^10.0.3" "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" "@types/deep-freeze-strict": "npm:^1.1.0" "@types/jest": "npm:^27.4.1" deep-freeze-strict: "npm:^1.1.1" @@ -3581,9 +3746,9 @@ __metadata: resolution: "@metamask/permission-log-controller@workspace:packages/permission-log-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/json-rpc-engine": "npm:^10.0.2" - "@metamask/utils": "npm:^11.0.1" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/json-rpc-engine": "npm:^10.0.3" + "@metamask/utils": "npm:^11.1.0" "@types/deep-freeze-strict": "npm:^1.1.0" "@types/jest": "npm:^27.4.1" deep-freeze-strict: "npm:^1.1.1" @@ -3597,13 +3762,13 @@ __metadata: languageName: unknown linkType: soft -"@metamask/phishing-controller@npm:^12.0.2, @metamask/phishing-controller@workspace:packages/phishing-controller": +"@metamask/phishing-controller@npm:^12.3.1, @metamask/phishing-controller@workspace:packages/phishing-controller": version: 0.0.0-use.local resolution: "@metamask/phishing-controller@workspace:packages/phishing-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" "@noble/hashes": "npm:^1.4.0" "@types/jest": "npm:^27.4.1" "@types/punycode": "npm:^2.1.0" @@ -3621,15 +3786,15 @@ __metadata: languageName: unknown linkType: soft -"@metamask/polling-controller@npm:^12.0.2, @metamask/polling-controller@workspace:packages/polling-controller": +"@metamask/polling-controller@npm:^12.0.3, @metamask/polling-controller@workspace:packages/polling-controller": version: 0.0.0-use.local resolution: "@metamask/polling-controller@workspace:packages/polling-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" - "@metamask/network-controller": "npm:^22.1.1" - "@metamask/utils": "npm:^11.0.1" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" + "@metamask/network-controller": "npm:^22.2.1" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" @@ -3646,24 +3811,24 @@ __metadata: languageName: unknown linkType: soft -"@metamask/post-message-stream@npm:^8.1.1": - version: 8.1.1 - resolution: "@metamask/post-message-stream@npm:8.1.1" +"@metamask/post-message-stream@npm:^9.0.0": + version: 9.0.0 + resolution: "@metamask/post-message-stream@npm:9.0.0" dependencies: - "@metamask/utils": "npm:^9.0.0" + "@metamask/utils": "npm:^11.0.1" readable-stream: "npm:3.6.2" - checksum: 10/8218d321abe734522aefaf6b44e4203966c3feaf83e2de6e68eef9dbe92b7fb47fe7fd82eae362147b1d741cc58d78bcc95d8bf02058e260ad2fb978104c96cf + checksum: 10/5da711d3274e724452322939a5a77c60ed1d7ed73cdaa62e95c16debc443804d5a16de116dce742e05b3fbfa962e009dfeafc3a12a66f20e163617567f2cace5 languageName: node linkType: hard -"@metamask/preferences-controller@npm:^15.0.1, @metamask/preferences-controller@workspace:packages/preferences-controller": +"@metamask/preferences-controller@npm:^15.0.2, @metamask/preferences-controller@workspace:packages/preferences-controller": version: 0.0.0-use.local resolution: "@metamask/preferences-controller@workspace:packages/preferences-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" - "@metamask/keyring-controller": "npm:^19.0.4" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" + "@metamask/keyring-controller": "npm:^19.1.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -3677,23 +3842,23 @@ __metadata: languageName: unknown linkType: soft -"@metamask/profile-sync-controller@npm:^4.1.1, @metamask/profile-sync-controller@workspace:packages/profile-sync-controller": +"@metamask/profile-sync-controller@npm:^8.0.0, @metamask/profile-sync-controller@workspace:packages/profile-sync-controller": version: 0.0.0-use.local resolution: "@metamask/profile-sync-controller@workspace:packages/profile-sync-controller" dependencies: "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" - "@metamask/accounts-controller": "npm:^21.0.2" + "@metamask/accounts-controller": "npm:^24.0.0" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/keyring-api": "npm:^14.0.0" - "@metamask/keyring-controller": "npm:^19.0.4" - "@metamask/keyring-internal-api": "npm:^2.0.1" - "@metamask/network-controller": "npm:^22.1.1" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/keyring-api": "npm:^17.0.0" + "@metamask/keyring-controller": "npm:^19.1.0" + "@metamask/keyring-internal-api": "npm:^4.0.1" + "@metamask/network-controller": "npm:^22.2.1" "@metamask/providers": "npm:^18.1.1" - "@metamask/snaps-controllers": "npm:^9.10.0" - "@metamask/snaps-sdk": "npm:^6.7.0" - "@metamask/snaps-utils": "npm:^8.3.0" + "@metamask/snaps-controllers": "npm:^9.19.0" + "@metamask/snaps-sdk": "npm:^6.17.1" + "@metamask/snaps-utils": "npm:^8.10.0" "@noble/ciphers": "npm:^0.5.2" "@noble/hashes": "npm:^1.4.0" "@types/jest": "npm:^27.4.1" @@ -3711,25 +3876,25 @@ __metadata: typescript: "npm:~5.2.2" webextension-polyfill: "npm:^0.12.0" peerDependencies: - "@metamask/accounts-controller": ^21.0.0 + "@metamask/accounts-controller": ^24.0.0 "@metamask/keyring-controller": ^19.0.0 "@metamask/network-controller": ^22.0.0 "@metamask/providers": ^18.1.0 - "@metamask/snaps-controllers": ^9.10.0 + "@metamask/snaps-controllers": ^9.19.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 languageName: unknown linkType: soft -"@metamask/providers@npm:^18.1.1": - version: 18.1.1 - resolution: "@metamask/providers@npm:18.1.1" +"@metamask/providers@npm:^18.1.1, @metamask/providers@npm:^18.3.1": + version: 18.3.1 + resolution: "@metamask/providers@npm:18.3.1" dependencies: - "@metamask/json-rpc-engine": "npm:^10.0.1" - "@metamask/json-rpc-middleware-stream": "npm:^8.0.5" + "@metamask/json-rpc-engine": "npm:^10.0.2" + "@metamask/json-rpc-middleware-stream": "npm:^8.0.6" "@metamask/object-multiplex": "npm:^2.0.0" - "@metamask/rpc-errors": "npm:^7.0.1" + "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/safe-event-emitter": "npm:^3.1.1" - "@metamask/utils": "npm:^10.0.0" + "@metamask/utils": "npm:^11.0.1" detect-browser: "npm:^5.2.0" extension-port-stream: "npm:^4.1.0" fast-deep-equal: "npm:^3.1.3" @@ -3737,7 +3902,7 @@ __metadata: readable-stream: "npm:^3.6.2" peerDependencies: webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/dca428d84e490343d85921d4fb09216a0b64be59a036d7b4f7b5ca4e2581c29a4106d58ff9dfe0650dc2b9387dd2adad508fc61073a9fda8ebde8ee3a5137abe + checksum: 10/0e21ba9cce926a49dedbfe30fc964cd2349ee6bf9156f525fb894dcbc147a3ae480384884131a6b1a0a508989b547d8c8d2aeb3d10e11f67a8ee5230c45631a8 languageName: node linkType: hard @@ -3746,14 +3911,14 @@ __metadata: resolution: "@metamask/queued-request-controller@workspace:packages/queued-request-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" - "@metamask/json-rpc-engine": "npm:^10.0.2" - "@metamask/network-controller": "npm:^22.1.1" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" + "@metamask/json-rpc-engine": "npm:^10.0.3" + "@metamask/network-controller": "npm:^22.2.1" "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/selected-network-controller": "npm:^21.0.0" + "@metamask/selected-network-controller": "npm:^21.0.1" "@metamask/swappable-obj-proxy": "npm:^2.3.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" @@ -3776,9 +3941,9 @@ __metadata: resolution: "@metamask/rate-limit-controller@workspace:packages/rate-limit-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" + "@metamask/base-controller": "npm:^8.0.0" "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -3795,11 +3960,10 @@ __metadata: dependencies: "@lavamoat/allow-scripts": "npm:^3.0.4" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" - "@metamask/utils": "npm:^11.0.1" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" - cockatiel: "npm:^3.1.2" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" nock: "npm:^13.3.1" @@ -3811,7 +3975,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/rpc-errors@npm:^7.0.0, @metamask/rpc-errors@npm:^7.0.1, @metamask/rpc-errors@npm:^7.0.2": +"@metamask/rpc-errors@npm:^7.0.2": version: 7.0.2 resolution: "@metamask/rpc-errors@npm:7.0.2" dependencies: @@ -3838,17 +4002,17 @@ __metadata: languageName: node linkType: hard -"@metamask/selected-network-controller@npm:^21.0.0, @metamask/selected-network-controller@workspace:packages/selected-network-controller": +"@metamask/selected-network-controller@npm:^21.0.1, @metamask/selected-network-controller@workspace:packages/selected-network-controller": version: 0.0.0-use.local resolution: "@metamask/selected-network-controller@workspace:packages/selected-network-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/json-rpc-engine": "npm:^10.0.2" - "@metamask/network-controller": "npm:^22.1.1" - "@metamask/permission-controller": "npm:^11.0.5" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/json-rpc-engine": "npm:^10.0.3" + "@metamask/network-controller": "npm:^22.2.1" + "@metamask/permission-controller": "npm:^11.0.6" "@metamask/swappable-obj-proxy": "npm:^2.3.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" @@ -3870,15 +4034,15 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/signature-controller@workspace:packages/signature-controller" dependencies: - "@metamask/approval-controller": "npm:^7.1.2" + "@metamask/approval-controller": "npm:^7.1.3" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" "@metamask/eth-sig-util": "npm:^8.0.0" - "@metamask/keyring-controller": "npm:^19.0.4" - "@metamask/logging-controller": "npm:^6.0.3" - "@metamask/network-controller": "npm:^22.1.1" - "@metamask/utils": "npm:^11.0.1" + "@metamask/keyring-controller": "npm:^19.1.0" + "@metamask/logging-controller": "npm:^6.0.4" + "@metamask/network-controller": "npm:^22.2.1" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -3897,107 +4061,111 @@ __metadata: languageName: unknown linkType: soft -"@metamask/slip44@npm:^4.0.0": - version: 4.0.0 - resolution: "@metamask/slip44@npm:4.0.0" - checksum: 10/3e47e8834b0fbdabe1f126fd78665767847ddc1f9ccc8defb23007dd71fcd2e4899c8ca04857491be3630668a3765bad1e40fdfca9a61ef33236d8d08e51535e +"@metamask/slip44@npm:^4.1.0": + version: 4.1.0 + resolution: "@metamask/slip44@npm:4.1.0" + checksum: 10/4265254a1800a24915bd1de15f86f196737132f9af2a084c2efc885decfc5dd87ad8f0687269d90b35e2ec64d3ea4fbff0caa793bcea6e585b1f3a290952b750 languageName: node linkType: hard -"@metamask/snaps-controllers@npm:^9.10.0": - version: 9.13.0 - resolution: "@metamask/snaps-controllers@npm:9.13.0" +"@metamask/snaps-controllers@npm:^9.19.0": + version: 9.19.1 + resolution: "@metamask/snaps-controllers@npm:9.19.1" dependencies: - "@metamask/approval-controller": "npm:^7.1.1" - "@metamask/base-controller": "npm:^7.0.2" - "@metamask/json-rpc-engine": "npm:^10.0.1" - "@metamask/json-rpc-middleware-stream": "npm:^8.0.5" - "@metamask/object-multiplex": "npm:^2.0.0" - "@metamask/permission-controller": "npm:^11.0.3" - "@metamask/phishing-controller": "npm:^12.0.2" - "@metamask/post-message-stream": "npm:^8.1.1" - "@metamask/rpc-errors": "npm:^7.0.1" - "@metamask/snaps-registry": "npm:^3.2.2" - "@metamask/snaps-rpc-methods": "npm:^11.5.1" - "@metamask/snaps-sdk": "npm:^6.11.0" - "@metamask/snaps-utils": "npm:^8.6.0" - "@metamask/utils": "npm:^10.0.0" + "@metamask/approval-controller": "npm:^7.1.2" + "@metamask/base-controller": "npm:^7.0.3" + "@metamask/json-rpc-engine": "npm:^10.0.2" + "@metamask/json-rpc-middleware-stream": "npm:^8.0.6" + "@metamask/key-tree": "npm:^10.0.2" + "@metamask/object-multiplex": "npm:^2.1.0" + "@metamask/permission-controller": "npm:^11.0.5" + "@metamask/phishing-controller": "npm:^12.3.1" + "@metamask/post-message-stream": "npm:^9.0.0" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/snaps-registry": "npm:^3.2.3" + "@metamask/snaps-rpc-methods": "npm:^11.11.0" + "@metamask/snaps-sdk": "npm:^6.17.1" + "@metamask/snaps-utils": "npm:^8.10.0" + "@metamask/utils": "npm:^11.0.1" "@xstate/fsm": "npm:^2.0.0" + async-mutex: "npm:^0.5.0" browserify-zlib: "npm:^0.2.0" concat-stream: "npm:^2.0.0" fast-deep-equal: "npm:^3.1.3" get-npm-tarball-url: "npm:^2.0.3" immer: "npm:^9.0.6" + luxon: "npm:^3.5.0" nanoid: "npm:^3.1.31" readable-stream: "npm:^3.6.2" readable-web-to-node-stream: "npm:^3.0.2" semver: "npm:^7.5.4" tar-stream: "npm:^3.1.7" peerDependencies: - "@metamask/snaps-execution-environments": ^6.10.0 + "@metamask/snaps-execution-environments": ^6.14.0 peerDependenciesMeta: "@metamask/snaps-execution-environments": optional: true - checksum: 10/bcf60b61de067f89439cb15acbdf6f808b4bcda8e1cbc9debd693ca2c545c9d38c4e6f380191c4703bd9d28d7dd41e4ce5111664d7b474d5e86e460bcefc3637 + checksum: 10/4744c6c3b5309b43f07c5f4f36169a0cda7c19b4565ed1925579b9d3831eb0cfcb204d73ce3c38a6c2d666f9c033b70320b4a0251bc2a10c0a678cc4e37b059e languageName: node linkType: hard -"@metamask/snaps-registry@npm:^3.2.2": - version: 3.2.2 - resolution: "@metamask/snaps-registry@npm:3.2.2" +"@metamask/snaps-registry@npm:^3.2.3": + version: 3.2.3 + resolution: "@metamask/snaps-registry@npm:3.2.3" dependencies: "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^10.0.0" + "@metamask/utils": "npm:^11.0.1" "@noble/curves": "npm:^1.2.0" "@noble/hashes": "npm:^1.3.2" - checksum: 10/ca8239e838bbb913435e166136bbc9bd7222c4bd87b1525fa7ae3cdf2e0b868b5d4d90a67d1ed49633d566bdef9243abdbf5f5937b85a85d24184087f555813e + checksum: 10/37760f29b7aaa337d815cf0c11fa34af5093d87fdc60a3750c494cf8bae6293cd52da03e7694b467b79733052d75ec6e3781ab3590d7259a050784e5be347d12 languageName: node linkType: hard -"@metamask/snaps-rpc-methods@npm:^11.5.1": - version: 11.5.1 - resolution: "@metamask/snaps-rpc-methods@npm:11.5.1" +"@metamask/snaps-rpc-methods@npm:^11.11.0": + version: 11.11.0 + resolution: "@metamask/snaps-rpc-methods@npm:11.11.0" dependencies: - "@metamask/key-tree": "npm:^9.1.2" - "@metamask/permission-controller": "npm:^11.0.3" - "@metamask/rpc-errors": "npm:^7.0.1" - "@metamask/snaps-sdk": "npm:^6.10.0" - "@metamask/snaps-utils": "npm:^8.5.0" + "@metamask/key-tree": "npm:^10.0.2" + "@metamask/permission-controller": "npm:^11.0.5" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/snaps-sdk": "npm:^6.17.0" + "@metamask/snaps-utils": "npm:^8.10.0" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^10.0.0" + "@metamask/utils": "npm:^11.0.1" "@noble/hashes": "npm:^1.3.1" - checksum: 10/0f999a5dd64f1b1123366f448ae833f0e95a415791600bb535959ba67d2269fbe3c4504d47f04db71bafa79a9a87d6b832fb2e2b5ef29567078c95bce2638f35 + luxon: "npm:^3.5.0" + checksum: 10/cd88db675062e848a65dc4edcd26ed24184430af77ed58f3e7949879255cbf94d1b5fcc51127646494a239c390fe6398c2ffaa5f3d2f63e7f859225e2eeae832 languageName: node linkType: hard -"@metamask/snaps-sdk@npm:^6.10.0, @metamask/snaps-sdk@npm:^6.11.0, @metamask/snaps-sdk@npm:^6.7.0": - version: 6.11.0 - resolution: "@metamask/snaps-sdk@npm:6.11.0" +"@metamask/snaps-sdk@npm:^6.17.0, @metamask/snaps-sdk@npm:^6.17.1": + version: 6.17.1 + resolution: "@metamask/snaps-sdk@npm:6.17.1" dependencies: - "@metamask/key-tree": "npm:^9.1.2" - "@metamask/providers": "npm:^18.1.1" - "@metamask/rpc-errors": "npm:^7.0.1" + "@metamask/key-tree": "npm:^10.0.2" + "@metamask/providers": "npm:^18.3.1" + "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^10.0.0" - checksum: 10/0f9b507139d1544b1b3d85ff8de81b800d543012d3ee9414c607c23abe9562e0dca48de089ed94be69f5ad981730a0f443371edfe6bc2d5ffb140b28e437bfd2 + "@metamask/utils": "npm:^11.0.1" + checksum: 10/05c5170c6250115535bc6d06a417157bb55005dd6fe86e768d70fabfba610ec8114cf45a8a5aad1219b1cfb0bcf5e080974735a0ac9a8c8bd0ac102f5c3cf42f languageName: node linkType: hard -"@metamask/snaps-utils@npm:^8.3.0, @metamask/snaps-utils@npm:^8.5.0, @metamask/snaps-utils@npm:^8.6.0": - version: 8.6.0 - resolution: "@metamask/snaps-utils@npm:8.6.0" +"@metamask/snaps-utils@npm:^8.10.0": + version: 8.10.0 + resolution: "@metamask/snaps-utils@npm:8.10.0" dependencies: "@babel/core": "npm:^7.23.2" "@babel/types": "npm:^7.23.0" - "@metamask/base-controller": "npm:^7.0.2" - "@metamask/key-tree": "npm:^9.1.2" - "@metamask/permission-controller": "npm:^11.0.3" - "@metamask/rpc-errors": "npm:^7.0.1" - "@metamask/slip44": "npm:^4.0.0" - "@metamask/snaps-registry": "npm:^3.2.2" - "@metamask/snaps-sdk": "npm:^6.11.0" + "@metamask/base-controller": "npm:^7.0.3" + "@metamask/key-tree": "npm:^10.0.2" + "@metamask/permission-controller": "npm:^11.0.5" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/slip44": "npm:^4.1.0" + "@metamask/snaps-registry": "npm:^3.2.3" + "@metamask/snaps-sdk": "npm:^6.17.0" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^10.0.0" + "@metamask/utils": "npm:^11.0.1" "@noble/hashes": "npm:^1.3.1" "@scure/base": "npm:^1.1.1" chalk: "npm:^4.1.2" @@ -4010,7 +4178,14 @@ __metadata: semver: "npm:^7.5.4" ses: "npm:^1.1.0" validate-npm-package-name: "npm:^5.0.0" - checksum: 10/c0f538f3f95e1875f6557b6ecc32f981bc4688d581af8cdc62c6c3ab8951c138286cd0b2d1cd82f769df24fcec10f71dcda67ae9a47edcff9ff73d52672df191 + checksum: 10/9c54c0d5632c9b01bacec3a497998e8111c6349fbee25452fd91acbbdc0e1230041b0b1cccba03799af3a14d973bd518c507bdf869f63ff95e875af0d6255aaf + languageName: node + linkType: hard + +"@metamask/stake-sdk@npm:^1.0.0": + version: 1.0.0 + resolution: "@metamask/stake-sdk@npm:1.0.0" + checksum: 10/96e3fff677aab96e9d26a98c719623ccac59a13e367f2a8fe66174fb00a36fbe32dd6b4664335801a690b2f3744010e6c8e88a4db678742dc6c0d04c0caaf9bb languageName: node linkType: hard @@ -4033,11 +4208,12 @@ __metadata: resolution: "@metamask/token-search-discovery-controller@workspace:packages/token-search-discovery-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/utils": "npm:^11.0.1" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" + nock: "npm:^13.3.1" ts-jest: "npm:^27.1.4" typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -4045,32 +4221,32 @@ __metadata: languageName: unknown linkType: soft -"@metamask/transaction-controller@npm:^43.0.0, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@npm:^46.0.0, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: "@babel/runtime": "npm:^7.23.9" - "@ethereumjs/common": "npm:^3.2.0" - "@ethereumjs/tx": "npm:^4.2.0" + "@ethereumjs/common": "npm:^4.4.0" + "@ethereumjs/tx": "npm:^5.4.0" "@ethereumjs/util": "npm:^8.1.0" "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^21.0.2" - "@metamask/approval-controller": "npm:^7.1.2" + "@metamask/accounts-controller": "npm:^24.0.0" + "@metamask/approval-controller": "npm:^7.1.3" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" "@metamask/eth-block-tracker": "npm:^11.0.3" - "@metamask/eth-json-rpc-provider": "npm:^4.1.7" + "@metamask/eth-json-rpc-provider": "npm:^4.1.8" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-provider-http": "npm:^0.3.0" - "@metamask/gas-fee-controller": "npm:^22.0.2" + "@metamask/gas-fee-controller": "npm:^22.0.3" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^22.1.1" + "@metamask/network-controller": "npm:^22.2.1" "@metamask/nonce-tracker": "npm:^6.0.0" "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/utils": "npm:^11.0.1" + "@metamask/utils": "npm:^11.1.0" "@types/bn.js": "npm:^5.1.5" "@types/jest": "npm:^27.4.1" "@types/node": "npm:^16.18.54" @@ -4091,7 +4267,7 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@babel/runtime": ^7.0.0 - "@metamask/accounts-controller": ^21.0.0 + "@metamask/accounts-controller": ^24.0.0 "@metamask/approval-controller": ^7.0.0 "@metamask/eth-block-tracker": ">=9" "@metamask/gas-fee-controller": ^22.0.0 @@ -4103,20 +4279,20 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/user-operation-controller@workspace:packages/user-operation-controller" dependencies: - "@metamask/approval-controller": "npm:^7.1.2" + "@metamask/approval-controller": "npm:^7.1.3" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^7.1.1" - "@metamask/controller-utils": "npm:^11.4.5" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/controller-utils": "npm:^11.5.0" "@metamask/eth-block-tracker": "npm:^11.0.3" "@metamask/eth-query": "npm:^4.0.0" - "@metamask/gas-fee-controller": "npm:^22.0.2" - "@metamask/keyring-controller": "npm:^19.0.4" - "@metamask/network-controller": "npm:^22.1.1" - "@metamask/polling-controller": "npm:^12.0.2" + "@metamask/gas-fee-controller": "npm:^22.0.3" + "@metamask/keyring-controller": "npm:^19.1.0" + "@metamask/network-controller": "npm:^22.2.1" + "@metamask/polling-controller": "npm:^12.0.3" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^43.0.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/transaction-controller": "npm:^46.0.0" + "@metamask/utils": "npm:^11.1.0" "@types/jest": "npm:^27.4.1" bn.js: "npm:^5.2.1" deepmerge: "npm:^4.2.2" @@ -4134,30 +4310,13 @@ __metadata: "@metamask/gas-fee-controller": ^22.0.0 "@metamask/keyring-controller": ^19.0.0 "@metamask/network-controller": ^22.0.0 - "@metamask/transaction-controller": ^43.0.0 + "@metamask/transaction-controller": ^46.0.0 languageName: unknown linkType: soft -"@metamask/utils@npm:^10.0.0": - version: 10.0.0 - resolution: "@metamask/utils@npm:10.0.0" - dependencies: - "@ethereumjs/tx": "npm:^4.2.0" - "@metamask/superstruct": "npm:^3.1.0" - "@noble/hashes": "npm:^1.3.1" - "@scure/base": "npm:^1.1.3" - "@types/debug": "npm:^4.1.7" - debug: "npm:^4.3.4" - pony-cause: "npm:^2.1.10" - semver: "npm:^7.5.4" - uuid: "npm:^9.0.1" - checksum: 10/9c2e6421f685d8a45145b6026a6f9fd0701eb5a2e8490fc6d18e64c103d5a62097f301cbc797790da52ceb5853bd9f65845c934b00299e69e5e6736c52b32f0f - languageName: node - linkType: hard - -"@metamask/utils@npm:^11.0.1": - version: 11.0.1 - resolution: "@metamask/utils@npm:11.0.1" +"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0": + version: 11.2.0 + resolution: "@metamask/utils@npm:11.2.0" dependencies: "@ethereumjs/tx": "npm:^4.2.0" "@metamask/superstruct": "npm:^3.1.0" @@ -4168,7 +4327,7 @@ __metadata: pony-cause: "npm:^2.1.10" semver: "npm:^7.5.4" uuid: "npm:^9.0.1" - checksum: 10/3949d16c8021bfb5f70e3b1c99f097ffaf43158116734197b039b32be6aabecb12178deb62c0b182e45295b0865618636324020059821c5b053029d8bdc90d70 + checksum: 10/9cc2cb6af4627085e72a310ba9b8921c69757d94e2992d4664627e5a0d99b1f2f7f8069c6f22262515135e1172bd66b82d00512d90ea2ec6da4e768f3d7d4ae2 languageName: node linkType: hard @@ -4189,7 +4348,7 @@ __metadata: languageName: node linkType: hard -"@metamask/utils@npm:^9.0.0, @metamask/utils@npm:^9.1.0, @metamask/utils@npm:^9.2.1": +"@metamask/utils@npm:^9.0.0, @metamask/utils@npm:^9.2.1": version: 9.3.0 resolution: "@metamask/utils@npm:9.3.0" dependencies: @@ -4593,6 +4752,82 @@ __metadata: languageName: node linkType: hard +"@solana/addresses@npm:^2.0.0": + version: 2.0.0 + resolution: "@solana/addresses@npm:2.0.0" + dependencies: + "@solana/assertions": "npm:2.0.0" + "@solana/codecs-core": "npm:2.0.0" + "@solana/codecs-strings": "npm:2.0.0" + "@solana/errors": "npm:2.0.0" + peerDependencies: + typescript: ">=5" + checksum: 10/f99d09c72046c73858aa8b7bc323e634a60b1023a4d280036bc94489e431075c7f29d2889e8787e33a04cfdecbe77cd8ca26c31ded73f735dc98e49c3151cc17 + languageName: node + linkType: hard + +"@solana/assertions@npm:2.0.0": + version: 2.0.0 + resolution: "@solana/assertions@npm:2.0.0" + dependencies: + "@solana/errors": "npm:2.0.0" + peerDependencies: + typescript: ">=5" + checksum: 10/c1af37ae1bd79b1657395d9315ac261dabc9908a64af6ed80e3b7e5140909cd8c8c757f0c41fff084e26fbb4d32f091c89c092a8c1ed5e6f4565dfe7426c0979 + languageName: node + linkType: hard + +"@solana/codecs-core@npm:2.0.0": + version: 2.0.0 + resolution: "@solana/codecs-core@npm:2.0.0" + dependencies: + "@solana/errors": "npm:2.0.0" + peerDependencies: + typescript: ">=5" + checksum: 10/e58a72e67bee3e5da60201eecda345c604b49138d5298e39b8e7d4d57a4dee47be3b0ecc8fc3429a2a60a42c952eaf860d43d3df1eb2b1d857e35368eca9c820 + languageName: node + linkType: hard + +"@solana/codecs-numbers@npm:2.0.0": + version: 2.0.0 + resolution: "@solana/codecs-numbers@npm:2.0.0" + dependencies: + "@solana/codecs-core": "npm:2.0.0" + "@solana/errors": "npm:2.0.0" + peerDependencies: + typescript: ">=5" + checksum: 10/500144d549ea0292c2f672300610df9054339a31cb6a4e61b29623308ef3b14f15eb587ee6139cf3334d2e0f29db1da053522da244b12184bb8fbdb097b7102b + languageName: node + linkType: hard + +"@solana/codecs-strings@npm:2.0.0": + version: 2.0.0 + resolution: "@solana/codecs-strings@npm:2.0.0" + dependencies: + "@solana/codecs-core": "npm:2.0.0" + "@solana/codecs-numbers": "npm:2.0.0" + "@solana/errors": "npm:2.0.0" + peerDependencies: + fastestsmallesttextencoderdecoder: ^1.0.22 + typescript: ">=5" + checksum: 10/4380136e2603c2cee12a28438817beb34b0fe45da222b8c38342c5b3680f02086ec7868cde0bb7b4e5dd459af5988613af1d97230c6a193db3be1c45122aba39 + languageName: node + linkType: hard + +"@solana/errors@npm:2.0.0": + version: 2.0.0 + resolution: "@solana/errors@npm:2.0.0" + dependencies: + chalk: "npm:^5.3.0" + commander: "npm:^12.1.0" + peerDependencies: + typescript: ">=5" + bin: + errors: bin/cli.mjs + checksum: 10/4191f96cad47c64266ec501ae1911a6245fd02b2f68a2c53c3dabbc63eb7c5462f170a765b584348b195da2387e7ca02096d792c67352c2c30a4f3a3cc7e4270 + languageName: node + linkType: hard + "@spruceid/siwe-parser@npm:2.1.0": version: 2.1.0 resolution: "@spruceid/siwe-parser@npm:2.1.0" @@ -4934,6 +5169,16 @@ __metadata: languageName: node linkType: hard +"@types/node-fetch@npm:^2.6.12": + version: 2.6.12 + resolution: "@types/node-fetch@npm:2.6.12" + dependencies: + "@types/node": "npm:*" + form-data: "npm:^4.0.0" + checksum: 10/8107c479da83a3114fcbfa882eba95ee5175cccb5e4dd53f737a96f2559ae6262f662176b8457c1656de09ec393cc7b20a266c077e4bfb21e929976e1cf4d0f9 + languageName: node + linkType: hard + "@types/node@npm:*, @types/node@npm:>=12.12.47, @types/node@npm:>=13.7.0": version: 22.5.0 resolution: "@types/node@npm:22.5.0" @@ -6960,6 +7205,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^12.1.0": + version: 12.1.0 + resolution: "commander@npm:12.1.0" + checksum: 10/cdaeb672d979816853a4eed7f1310a9319e8b976172485c2a6b437ed0db0a389a44cfb222bfbde772781efa9f215bdd1b936f80d6b249485b465c6cb906e1f93 + languageName: node + linkType: hard + "commander@npm:^9.0.0": version: 9.5.0 resolution: "commander@npm:9.5.0" @@ -7969,7 +8221,7 @@ __metadata: languageName: node linkType: hard -"ethereum-cryptography@npm:^2.0.0, ethereum-cryptography@npm:^2.1.2": +"ethereum-cryptography@npm:^2.0.0, ethereum-cryptography@npm:^2.1.2, ethereum-cryptography@npm:^2.2.1": version: 2.2.1 resolution: "ethereum-cryptography@npm:2.2.1" dependencies: @@ -8342,38 +8594,39 @@ __metadata: languageName: node linkType: hard -"firebase@npm:^10.11.0": - version: 10.13.0 - resolution: "firebase@npm:10.13.0" - dependencies: - "@firebase/analytics": "npm:0.10.7" - "@firebase/analytics-compat": "npm:0.2.13" - "@firebase/app": "npm:0.10.9" - "@firebase/app-check": "npm:0.8.7" - "@firebase/app-check-compat": "npm:0.3.14" - "@firebase/app-compat": "npm:0.2.39" - "@firebase/app-types": "npm:0.9.2" - "@firebase/auth": "npm:1.7.7" - "@firebase/auth-compat": "npm:0.5.12" - "@firebase/database": "npm:1.0.7" - "@firebase/database-compat": "npm:1.0.7" - "@firebase/firestore": "npm:4.7.0" - "@firebase/firestore-compat": "npm:0.3.35" - "@firebase/functions": "npm:0.11.6" - "@firebase/functions-compat": "npm:0.3.12" - "@firebase/installations": "npm:0.6.8" - "@firebase/installations-compat": "npm:0.2.8" - "@firebase/messaging": "npm:0.12.10" - "@firebase/messaging-compat": "npm:0.2.10" - "@firebase/performance": "npm:0.6.8" - "@firebase/performance-compat": "npm:0.2.8" - "@firebase/remote-config": "npm:0.4.8" - "@firebase/remote-config-compat": "npm:0.2.8" - "@firebase/storage": "npm:0.13.0" - "@firebase/storage-compat": "npm:0.3.10" - "@firebase/util": "npm:1.9.7" - "@firebase/vertexai-preview": "npm:0.0.3" - checksum: 10/dd4d62acb3146cb96f88a98eead8a5a02ef42dc5f5a918bbf496f2f894a048ff9aef64b79f2dc8909995b7d3ad2d4d36d6a72add7c8ef3ee46cb811641fc572a +"firebase@npm:^11.2.0": + version: 11.2.0 + resolution: "firebase@npm:11.2.0" + dependencies: + "@firebase/analytics": "npm:0.10.11" + "@firebase/analytics-compat": "npm:0.2.17" + "@firebase/app": "npm:0.10.18" + "@firebase/app-check": "npm:0.8.11" + "@firebase/app-check-compat": "npm:0.3.18" + "@firebase/app-compat": "npm:0.2.48" + "@firebase/app-types": "npm:0.9.3" + "@firebase/auth": "npm:1.8.2" + "@firebase/auth-compat": "npm:0.5.17" + "@firebase/data-connect": "npm:0.2.0" + "@firebase/database": "npm:1.0.11" + "@firebase/database-compat": "npm:2.0.2" + "@firebase/firestore": "npm:4.7.6" + "@firebase/firestore-compat": "npm:0.3.41" + "@firebase/functions": "npm:0.12.1" + "@firebase/functions-compat": "npm:0.3.18" + "@firebase/installations": "npm:0.6.12" + "@firebase/installations-compat": "npm:0.2.12" + "@firebase/messaging": "npm:0.12.16" + "@firebase/messaging-compat": "npm:0.2.16" + "@firebase/performance": "npm:0.6.12" + "@firebase/performance-compat": "npm:0.2.12" + "@firebase/remote-config": "npm:0.5.0" + "@firebase/remote-config-compat": "npm:0.2.12" + "@firebase/storage": "npm:0.13.5" + "@firebase/storage-compat": "npm:0.3.15" + "@firebase/util": "npm:1.10.3" + "@firebase/vertexai": "npm:1.0.3" + checksum: 10/9a3a8f6be4b34e76428cf6ae11bff8141772b7b3ec8a8fe0ef69188fdf2a602bd6e542a663133f90845d5a358daadeadf760841cf3ea8ec726475ee84a694ea4 languageName: node linkType: hard @@ -10525,7 +10778,7 @@ __metadata: languageName: node linkType: hard -"luxon@npm:^3.2.1": +"luxon@npm:^3.2.1, luxon@npm:^3.5.0": version: 3.5.0 resolution: "luxon@npm:3.5.0" checksum: 10/48f86e6c1c96815139f8559456a3354a276ba79bcef0ae0d4f2172f7652f3ba2be2237b0e103b8ea0b79b47715354ac9fac04eb1db3485dcc72d5110491dd47f @@ -10923,7 +11176,7 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^2.6.1": +"node-fetch@npm:^2.6.1, node-fetch@npm:^2.7.0": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" dependencies: @@ -11486,6 +11739,15 @@ __metadata: languageName: node linkType: hard +"prettier-2@npm:prettier@^2.8.8, prettier@npm:^2.8.8": + version: 2.8.8 + resolution: "prettier@npm:2.8.8" + bin: + prettier: bin-prettier.js + checksum: 10/00cdb6ab0281f98306cd1847425c24cbaaa48a5ff03633945ab4c701901b8e96ad558eb0777364ffc312f437af9b5a07d0f45346266e8245beaf6247b9c62b24 + languageName: node + linkType: hard + "prettier-linter-helpers@npm:^1.0.0": version: 1.0.0 resolution: "prettier-linter-helpers@npm:1.0.0" @@ -11510,15 +11772,6 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^2.8.8": - version: 2.8.8 - resolution: "prettier@npm:2.8.8" - bin: - prettier: bin-prettier.js - checksum: 10/00cdb6ab0281f98306cd1847425c24cbaaa48a5ff03633945ab4c701901b8e96ad558eb0777364ffc312f437af9b5a07d0f45346266e8245beaf6247b9c62b24 - languageName: node - linkType: hard - "prettier@npm:^3.3.3": version: 3.4.2 resolution: "prettier@npm:3.4.2" @@ -13127,15 +13380,6 @@ __metadata: languageName: node linkType: hard -"undici@npm:5.28.4": - version: 5.28.4 - resolution: "undici@npm:5.28.4" - dependencies: - "@fastify/busboy": "npm:^2.0.0" - checksum: 10/a666a9f5ac4270c659fafc33d78b6b5039a0adbae3e28f934774c85dcc66ea91da907896f12b414bd6f578508b44d5dc206fa636afa0e49a4e1c9e99831ff065 - languageName: node - linkType: hard - "unique-filename@npm:^3.0.0": version: 3.0.0 resolution: "unique-filename@npm:3.0.0"